mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b783209fd | ||
|
|
03943d8a84 | ||
|
|
3db2fc12a6 | ||
|
|
847d450594 | ||
|
|
289033df01 | ||
|
|
2fff350d61 | ||
|
|
a5b6cbf977 | ||
|
|
e115d0da4a | ||
|
|
0e43c6fbba | ||
|
|
de8dad66d6 | ||
|
|
0b455f4c81 | ||
|
|
01000d4646 | ||
|
|
18015b1faf | ||
|
|
7586f9e785 | ||
|
|
abeb6b1b36 | ||
|
|
1317859a0c | ||
|
|
8447cc10ac | ||
|
|
03cdec40a2 | ||
|
|
f65f584c3f | ||
|
|
bda618594f | ||
|
|
98c340be47 | ||
|
|
87d20e14b3 | ||
|
|
4779d91c5b | ||
|
|
07d2d4bdd0 | ||
|
|
2e2566c84d | ||
|
|
1b0fbd8bb1 | ||
|
|
6fd42831e2 | ||
|
|
e386c45525 | ||
|
|
0fac934b3f | ||
|
|
48cad29d31 | ||
|
|
0f26eda9f4 | ||
|
|
14649962fc | ||
|
|
0f81082776 | ||
|
|
31db766ff1 | ||
|
|
05c8743f03 | ||
|
|
be92796684 | ||
|
|
6264845f53 | ||
|
|
20c2705623 | ||
|
|
1923e50a34 | ||
|
|
7c388628f2 | ||
|
|
339f443ffb | ||
|
|
c8afd3be60 | ||
|
|
756c6159a6 | ||
|
|
26f72585ee | ||
|
|
5b80976176 | ||
|
|
044d2ee025 | ||
|
|
081f35e9dd | ||
|
|
6e56478321 | ||
|
|
37ec853810 | ||
|
|
73a1b23696 | ||
|
|
8c71d355b0 | ||
|
|
155ffaa49b | ||
|
|
ef0ceacf78 | ||
|
|
552f48ab7a | ||
|
|
e8b13d13dd | ||
|
|
ae46c30d3e | ||
|
|
411e19d87e | ||
|
|
c155364b3d | ||
|
|
e89b435af9 | ||
|
|
0549af617b | ||
|
|
ce51afd2dc | ||
|
|
2bfb7de07a | ||
|
|
8658f020af | ||
|
|
0a38f2e47a | ||
|
|
7c7e3ff99c | ||
|
|
868a14cadc | ||
|
|
63da66261c | ||
|
|
76ed2566ed | ||
|
|
e808b48946 | ||
|
|
817d78b399 | ||
|
|
813af4958e | ||
|
|
b0786368ca | ||
|
|
c457700233 | ||
|
|
c4375bcde9 | ||
|
|
f5c4d463b9 | ||
|
|
402b524fd7 | ||
|
|
a846cf7fc1 | ||
|
|
87d9ed6150 | ||
|
|
9d5775c789 | ||
|
|
aadd62e2e5 | ||
|
|
77d875db8d | ||
|
|
c9fb8f7ee3 | ||
|
|
1b3d1e0c8d | ||
|
|
6420f14fda | ||
|
|
f200f6108f | ||
|
|
572e0d54b3 | ||
|
|
0a43322267 | ||
|
|
1ff0525a34 | ||
|
|
ec45959a6e | ||
|
|
253733c1f9 | ||
|
|
32f597d16e | ||
|
|
c78b996d13 | ||
|
|
933ee31d6c | ||
|
|
83bdeb81f0 | ||
|
|
3cf430d823 | ||
|
|
efcd3bbf51 | ||
|
|
a3303025fe | ||
|
|
6ebff84af2 | ||
|
|
5e00091026 | ||
|
|
4c6ba31a1c | ||
|
|
5868b02bdf | ||
|
|
ab6052b8a2 | ||
|
|
d437997362 | ||
|
|
e4c97fdfab | ||
|
|
c8eb8693fa | ||
|
|
851f5fe19f | ||
|
|
7c26f0d5ec | ||
|
|
0e04e0d753 | ||
|
|
f05b2392c3 | ||
|
|
e41bddb590 | ||
|
|
e7cc0d9841 | ||
|
|
61d2f00f17 | ||
|
|
7c4f42b1c0 | ||
|
|
cc79075291 | ||
|
|
1fbc0f52dd | ||
|
|
195e048b55 | ||
|
|
b1106b98f3 | ||
|
|
53ca70d347 | ||
|
|
e000d91b13 | ||
|
|
dc768371ab | ||
|
|
e9b0b23224 | ||
|
|
7f4d00ca95 | ||
|
|
470cc01b0a | ||
|
|
ff1ba768f0 | ||
|
|
de226cae50 | ||
|
|
af59635bf6 | ||
|
|
281029d1a8 | ||
|
|
3bf7491840 | ||
|
|
4d29b0bf74 | ||
|
|
1d98de9d4b | ||
|
|
7ec53ac034 | ||
|
|
f78be8c399 | ||
|
|
97a5ba687c | ||
|
|
cb6fe7ef9c | ||
|
|
5a0cf3f6ea | ||
|
|
bd62832a62 | ||
|
|
9e95d0837e | ||
|
|
f0aca7b212 | ||
|
|
b3b5ef409f | ||
|
|
b7da613abe | ||
|
|
4d0794b8f0 | ||
|
|
ca80fec2fc | ||
|
|
1b8b56aa65 | ||
|
|
1cdbb9303f | ||
|
|
8beea984d0 | ||
|
|
8a4c879184 | ||
|
|
f1bbea3885 | ||
|
|
863575ebf7 | ||
|
|
6a33920ae7 | ||
|
|
965b9c1af8 | ||
|
|
802f368ce8 | ||
|
|
c93eabbe4c | ||
|
|
3fbc2dc105 | ||
|
|
b2b1b23027 | ||
|
|
b0123ea2f9 | ||
|
|
53cd9a0ac3 | ||
|
|
0e404acc2e | ||
|
|
15078fbdb6 | ||
|
|
2cde9eb7ce | ||
|
|
cf4ed6d7c6 | ||
|
|
280e48f4dd | ||
|
|
e239a07459 | ||
|
|
77aaa519eb | ||
|
|
25f26b071d | ||
|
|
448ff4d19c | ||
|
|
f4f342cecf | ||
|
|
dff8c7dc16 | ||
|
|
df02bd5938 | ||
|
|
22aeea5cd6 | ||
|
|
9919c5c4d1 | ||
|
|
52ce4c5dd1 | ||
|
|
81f368fb94 | ||
|
|
870a5ef3cd | ||
|
|
b5833bff78 | ||
|
|
e512b30cf1 | ||
|
|
9c25f0e2e6 | ||
|
|
ec63de72e5 | ||
|
|
98d83116de | ||
|
|
b9c03d4a95 | ||
|
|
c8c4162818 | ||
|
|
5e1c9b652f | ||
|
|
8472468039 | ||
|
|
b666c13dd4 | ||
|
|
12bad68c6a | ||
|
|
c4927af867 | ||
|
|
7b43b14559 | ||
|
|
58c0b7b4a8 | ||
|
|
ae2ade4485 | ||
|
|
9549614364 | ||
|
|
b2abc6b103 | ||
|
|
e28583407c | ||
|
|
20880b24b1 | ||
|
|
a54fd45859 | ||
|
|
06c8ca12e7 | ||
|
|
98e3406998 | ||
|
|
33c66feebd | ||
|
|
bf6ff1be7f | ||
|
|
af75bc400f | ||
|
|
6a733bb779 | ||
|
|
d29b99824e | ||
|
|
3d0fae17aa | ||
|
|
d7d32602c1 | ||
|
|
c4e07cbe0c | ||
|
|
2221374763 | ||
|
|
0b8c08fd53 | ||
|
|
637d1aa4c8 | ||
|
|
4e09005d01 | ||
|
|
945a3de38f | ||
|
|
112ea17e8d | ||
|
|
69f88a7dbc | ||
|
|
febf34475a | ||
|
|
ad4098781c | ||
|
|
eb386d72ac | ||
|
|
d8948232ef | ||
|
|
14117d1d33 | ||
|
|
2228d88625 | ||
|
|
c1b11291cf | ||
|
|
8e89cefdd0 | ||
|
|
3283baaa6d | ||
|
|
8473abce76 | ||
|
|
d13c2ce4c2 | ||
|
|
7abb8c1429 | ||
|
|
6a39ba41b5 | ||
|
|
0355736aaf | ||
|
|
efa33a20f4 | ||
|
|
288a47b341 | ||
|
|
65a5f4fd53 | ||
|
|
cfdc832472 | ||
|
|
c6a5fb039b | ||
|
|
29c7d74ce8 | ||
|
|
f5db50570c | ||
|
|
948604dfee | ||
|
|
a867ab1466 | ||
|
|
7ee8ae0174 | ||
|
|
1cb5919859 | ||
|
|
0dbb8abc1e | ||
|
|
ac07cd4967 | ||
|
|
5f937aa60d | ||
|
|
14e6b7c264 | ||
|
|
8a18aa87e6 | ||
|
|
92d544fc78 | ||
|
|
4e74324227 | ||
|
|
a4c94f26cd | ||
|
|
d66f91ea27 | ||
|
|
7416015e68 | ||
|
|
954c8ff8e6 | ||
|
|
e9fbb51d45 | ||
|
|
263344eae7 | ||
|
|
0dca195e5c | ||
|
|
e7bcc1e4b4 | ||
|
|
8a58309a39 | ||
|
|
80b9d44fb0 | ||
|
|
ccd4e35459 | ||
|
|
7480ae88fa | ||
|
|
2688439ae2 | ||
|
|
3d50eddccc | ||
|
|
083e0b3369 | ||
|
|
a87cb3ac52 | ||
|
|
5d80ed0c50 | ||
|
|
8ab2315dd7 | ||
|
|
d8ae172f20 | ||
|
|
fbcd7d077d | ||
|
|
845da7ab45 | ||
|
|
543c28d7e5 | ||
|
|
6affac79d3 | ||
|
|
00d05b1069 | ||
|
|
12aa7764cf | ||
|
|
2942eead2c | ||
|
|
fb1cbc5476 | ||
|
|
d1fccb0db2 | ||
|
|
07c77d946f | ||
|
|
86b1663194 | ||
|
|
29c042439b | ||
|
|
e0f087d0cc | ||
|
|
3ada521676 | ||
|
|
0b9f9ba258 | ||
|
|
a7fbbe1797 | ||
|
|
7196c57600 | ||
|
|
93b39b7559 | ||
|
|
b83be788f4 | ||
|
|
a922116e9d | ||
|
|
4f0cd15d62 | ||
|
|
f28fdf47f1 | ||
|
|
4c043f7b50 | ||
|
|
62b5435558 | ||
|
|
0692adbaaf | ||
|
|
f90ab3ccec | ||
|
|
a5de9732e4 | ||
|
|
ad1a116854 | ||
|
|
ad6ab61c10 | ||
|
|
6cf29ac1b7 | ||
|
|
2f40d46434 | ||
|
|
4d85f9882f | ||
|
|
17f5a5621e | ||
|
|
1a06fb1167 | ||
|
|
c370090b8b | ||
|
|
f4b199cc68 | ||
|
|
200db241af | ||
|
|
d6a51d576b | ||
|
|
bf34fd5d3a | ||
|
|
9b74480018 | ||
|
|
90638d6ff3 | ||
|
|
7d2e9b9457 | ||
|
|
01681c2083 | ||
|
|
f712df203d | ||
|
|
9c0b515b94 | ||
|
|
08d257b629 | ||
|
|
15ca4a7a5c | ||
|
|
7656b39895 | ||
|
|
bd29399c9b | ||
|
|
e833af629b | ||
|
|
4ba8900a16 | ||
|
|
1bcb35a395 | ||
|
|
492318c235 | ||
|
|
2d3b791634 | ||
|
|
0214f174e2 | ||
|
|
3fafc4a528 | ||
|
|
9b89318ef5 | ||
|
|
a0a50379ce | ||
|
|
24a506d553 | ||
|
|
0155e10ca2 | ||
|
|
cd1519f4d3 | ||
|
|
002a3216fe | ||
|
|
3c175b156d | ||
|
|
00601023cc | ||
|
|
91e4854d2f | ||
|
|
627ee68b83 | ||
|
|
16bc3c6919 | ||
|
|
520885013a | ||
|
|
bd18fe8f9d | ||
|
|
6e72cc30e8 | ||
|
|
0f3f03aa93 | ||
|
|
0c428a31f8 | ||
|
|
aa1fcea7e9 | ||
|
|
ca167b6c47 | ||
|
|
b2db4be204 | ||
|
|
9937a1cb4a | ||
|
|
3acc6dbaaf | ||
|
|
6734594790 | ||
|
|
e8c432a024 | ||
|
|
04ad0e3c25 | ||
|
|
ca59c7cc00 | ||
|
|
732d5f94d8 | ||
|
|
4464970e1d | ||
|
|
fe55132d55 | ||
|
|
f4f32cc7d3 | ||
|
|
ff34193a55 | ||
|
|
94424bda54 | ||
|
|
9c4393d30d | ||
|
|
b4c548b1a8 | ||
|
|
8e203505d9 | ||
|
|
5c97da241b | ||
|
|
10709e6642 | ||
|
|
1462713862 | ||
|
|
8f7690f021 | ||
|
|
8b8ca36057 | ||
|
|
e7f13057ae | ||
|
|
d23cfeb216 | ||
|
|
8bf1c73193 | ||
|
|
962da251ec | ||
|
|
cdd3d9add2 | ||
|
|
b0a0adc2fe | ||
|
|
39d90df113 | ||
|
|
d10bdfaad3 | ||
|
|
1dcf6af8d3 | ||
|
|
032ae8f084 | ||
|
|
43598a1dbc | ||
|
|
3197b4c563 | ||
|
|
e05823be3e | ||
|
|
594a2bd740 | ||
|
|
c338eb9a5b | ||
|
|
a51e003255 | ||
|
|
21ebb4770d | ||
|
|
92a7ae08e2 | ||
|
|
23102a7e8b | ||
|
|
46dd272fc4 | ||
|
|
bfdc7e2a1d | ||
|
|
5f874dc1d4 | ||
|
|
e2c1587e77 | ||
|
|
9d18948916 | ||
|
|
a1b4dd382b | ||
|
|
2e57b9bf90 | ||
|
|
a5d5e76531 | ||
|
|
fb7ef5b50b | ||
|
|
8ee94c1039 | ||
|
|
24e5559fca | ||
|
|
765ac8aa23 | ||
|
|
960d437962 | ||
|
|
7574157761 | ||
|
|
d3cd11eecd | ||
|
|
7dd377fe47 | ||
|
|
3323381609 | ||
|
|
43e78eda03 | ||
|
|
5dc2f28c9e | ||
|
|
ff391889b4 | ||
|
|
a8af9ee097 | ||
|
|
84ebeedc06 | ||
|
|
10449cbd87 | ||
|
|
eae50a7a58 | ||
|
|
e0eba2e4a7 | ||
|
|
d3b6daef14 | ||
|
|
f832f84a39 | ||
|
|
0767a73bd3 | ||
|
|
da62677471 | ||
|
|
522f7d1f01 | ||
|
|
862f8865c4 | ||
|
|
86dcc1b9b8 | ||
|
|
0de5fe17c4 | ||
|
|
068dfffaa1 | ||
|
|
e461d48992 | ||
|
|
52c86b5e36 | ||
|
|
c5483809c6 | ||
|
|
34cad82075 | ||
|
|
8def264b95 | ||
|
|
9c89690c25 | ||
|
|
a2289aa88a | ||
|
|
47a4010016 | ||
|
|
d0b1bc8fd2 | ||
|
|
3483500174 | ||
|
|
111debd2b7 | ||
|
|
bfb4edc753 | ||
|
|
10d87b83a3 | ||
|
|
628a5acaa8 | ||
|
|
80353df7ef | ||
|
|
810d6c33d8 | ||
|
|
ed18015c1d | ||
|
|
9d9926e48d | ||
|
|
df2ae96613 | ||
|
|
498d2af00b | ||
|
|
751974427d | ||
|
|
2b6405327d | ||
|
|
aebed7fb56 | ||
|
|
cbaaeb98f8 | ||
|
|
6eda696596 | ||
|
|
f2b44860c4 | ||
|
|
ce6c6d144f | ||
|
|
14d1d36915 | ||
|
|
8b1218dd48 | ||
|
|
b4386d746f | ||
|
|
f066c3ad83 | ||
|
|
66cf4d8970 | ||
|
|
c78941743c | ||
|
|
c1c3419343 | ||
|
|
c6fb0eb95b | ||
|
|
f941eec2e1 | ||
|
|
5b51c01280 | ||
|
|
ec7a7e29ee | ||
|
|
c84e1682e7 | ||
|
|
be3b0cac19 | ||
|
|
5f78e0b21e | ||
|
|
cd01aefd1d | ||
|
|
f360221b06 | ||
|
|
c3e3979414 | ||
|
|
11169ceb3b | ||
|
|
5834759fb1 | ||
|
|
c3563fc225 | ||
|
|
0049f6d880 | ||
|
|
0a8829fbb8 | ||
|
|
93aa8daf61 | ||
|
|
f46e8a274b | ||
|
|
e3bfdf96c4 | ||
|
|
b14f1ea829 | ||
|
|
d1cc623de8 | ||
|
|
d7e6e0129b | ||
|
|
87550bbda1 | ||
|
|
ff09e4edc2 | ||
|
|
0b21cafee4 | ||
|
|
14804c7c7c | ||
|
|
dddcf2b3d5 | ||
|
|
a6955a49fa | ||
|
|
42e8d2e5ae | ||
|
|
e79f2b8168 | ||
|
|
71212621b4 | ||
|
|
df24cd4ece | ||
|
|
c97d7b6afa | ||
|
|
a30f26dafa | ||
|
|
1a649b2ffa | ||
|
|
5326f02ccb | ||
|
|
97c3ebbbf2 | ||
|
|
6b3f2cab88 | ||
|
|
7f7f64e7fb | ||
|
|
6af297b927 | ||
|
|
a0ce00b5f7 | ||
|
|
5cbedc4916 | ||
|
|
2db788599f | ||
|
|
5050eef812 | ||
|
|
20903c3470 | ||
|
|
782ad209bd | ||
|
|
efdbf9788c | ||
|
|
d15b1180cf | ||
|
|
5d560c2879 | ||
|
|
bbef48cc65 | ||
|
|
52534f832b | ||
|
|
d5ad9ba17f | ||
|
|
0280ffb399 | ||
|
|
2948ec98ec | ||
|
|
d0f2be43a0 | ||
|
|
1bc4c58aa5 | ||
|
|
38143faff0 | ||
|
|
cb465b4109 | ||
|
|
7381820724 | ||
|
|
7fae97de84 | ||
|
|
419b65dbca | ||
|
|
47fb2f3ac2 | ||
|
|
27cc9f4191 | ||
|
|
3933dbed40 | ||
|
|
a377a43652 | ||
|
|
0e1dff5b5a | ||
|
|
5d73c8e205 | ||
|
|
22f02727f6 | ||
|
|
e3e06303f5 | ||
|
|
fd6fa05b19 | ||
|
|
bbdfaebdf3 | ||
|
|
3d6e532b0e | ||
|
|
f06c3efca5 | ||
|
|
40b9b8f211 | ||
|
|
055b60bc0c | ||
|
|
1fd91e8f4a | ||
|
|
3e9e807132 | ||
|
|
29c1e04267 | ||
|
|
715a9f11fb | ||
|
|
d88acbe67d | ||
|
|
d0289a4d0f | ||
|
|
87c91ebd06 | ||
|
|
bfc838a3ce | ||
|
|
eef0e8590b | ||
|
|
c22dd9495d | ||
|
|
ffe1b34bda | ||
|
|
cee09e76ba | ||
|
|
bbb9cf74a9 | ||
|
|
b70010f018 | ||
|
|
5bc01bedd2 | ||
|
|
a341d5615e | ||
|
|
cb46820a92 | ||
|
|
da640949c8 | ||
|
|
562449cd9d | ||
|
|
863f66cb7e | ||
|
|
2662c799d6 | ||
|
|
7e1cdccb8d | ||
|
|
4aeb419ea9 | ||
|
|
2e2a484781 | ||
|
|
611094ba17 | ||
|
|
ad2c963475 | ||
|
|
0bb0f44785 | ||
|
|
5766a8ebeb | ||
|
|
3d0fe85e7e | ||
|
|
8c785fa4b0 | ||
|
|
b364c1693c | ||
|
|
0bbcefcb59 | ||
|
|
448b8bdf5c | ||
|
|
b6a1d9b5f3 | ||
|
|
e5a6c3ae93 | ||
|
|
9eacc9afa9 | ||
|
|
82c27867b3 | ||
|
|
fb02f44834 | ||
|
|
be5c98c744 | ||
|
|
a87c4d1fff | ||
|
|
ea2c04d046 | ||
|
|
60caacd887 | ||
|
|
860651f866 | ||
|
|
2f63333c56 | ||
|
|
c43bd2bdc3 | ||
|
|
457b586331 | ||
|
|
678cf19f03 | ||
|
|
35791b47dd | ||
|
|
0b482f3283 | ||
|
|
5ae138c556 | ||
|
|
fb6c043d2d | ||
|
|
91b5467cba | ||
|
|
9b5b9dbce6 | ||
|
|
4cef3671b3 | ||
|
|
5b7f6a3057 | ||
|
|
03c8dacd0a | ||
|
|
fc63bc00a6 | ||
|
|
7142f95dba | ||
|
|
b3c6f06533 | ||
|
|
e956185f58 | ||
|
|
23c7322789 | ||
|
|
f9718c5552 | ||
|
|
2648d9b684 | ||
|
|
eb2910aada | ||
|
|
9e4c73d280 |
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Logins allowed to open pull requests, one per line, matched case-insensitively.
|
||||
# Repository owners, members and collaborators, and bots, are always allowed.
|
||||
lancy69
|
||||
chuiwenwei
|
||||
xxoingr
|
||||
kevin9327
|
||||
imcreazy
|
||||
@@ -2,6 +2,9 @@
|
||||
Target branch: 2.x features and fixes go to `studio`. 1.x takes bug fixes,
|
||||
provider/API compatibility, release/updater, security and platform stability
|
||||
only, on `main-v2`.
|
||||
|
||||
Pull requests are limited to maintainers and invited contributors; one from
|
||||
anyone else is closed automatically. Open an issue instead.
|
||||
-->
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
[
|
||||
{
|
||||
"package": "reasonix/internal/runtime/delegation",
|
||||
"tests": [
|
||||
"TestAncestorCopyAdmissionCoversUnpublishedRun",
|
||||
"TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_continue",
|
||||
"TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_legacy",
|
||||
"TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_continue",
|
||||
"TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_legacy",
|
||||
"TestAncestorCopyAdmissionAllowsDifferentParents",
|
||||
"TestAncestorCopyAdmissionReleasesAbandonedPreparation",
|
||||
"TestAncestorCopyAdmissionReleasesAfterInvalidCopy",
|
||||
"TestAncestorCopyAdmissionAllowsDifferentSources",
|
||||
"TestAncestorCopyAdmissionUsesNearestSourceIdentity",
|
||||
"TestAncestorCopyAdmissionReleasesAfterBusyCurrentReference",
|
||||
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata",
|
||||
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata/continue_from",
|
||||
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata/fork_from",
|
||||
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground",
|
||||
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground/continue_from",
|
||||
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground/fork_from"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
[
|
||||
{
|
||||
"package": "reasonix/internal/runtime/agent",
|
||||
"tests": [
|
||||
"TestCarriedProofInvalidatesPermissionChange",
|
||||
"TestCarriedProofFileStateCompatibility",
|
||||
"TestCarriedProofFileStateCompatibility/legacy_file",
|
||||
"TestCarriedProofFileStateCompatibility/legacy_absent",
|
||||
"TestCarriedProofFileStateCompatibility/future",
|
||||
"TestCarriedProofFileStateCompatibility/current_file",
|
||||
"TestCarriedProofFileStateCompatibility/current_absent",
|
||||
"TestCarriedProofRejectsDirectoryState",
|
||||
"TestCarriedProofStandsOnlyForUnchangedContent",
|
||||
"TestCarriedProofSeesADeletion"
|
||||
]
|
||||
},
|
||||
{
|
||||
"package": "reasonix/internal/assembly/boot",
|
||||
"tests": [
|
||||
"TestEffectCarriedProofTracksFileState",
|
||||
"TestEffectCarriedProofTracksFileState/unchanged",
|
||||
"TestEffectCarriedProofTracksFileState/content",
|
||||
"TestEffectCarriedProofTracksFileState/mode"
|
||||
]
|
||||
}
|
||||
]
|
||||
+164
-317
@@ -15,7 +15,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/studio' }}
|
||||
|
||||
jobs:
|
||||
# Cheap path gate for pull requests. PRs confined to docs/release-notes
|
||||
@@ -170,23 +170,6 @@ jobs:
|
||||
shell: pwsh
|
||||
run: Get-CimInstance Win32_Processor | Select-Object -ExpandProperty Name
|
||||
|
||||
- name: test (Windows concurrency contracts)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
node --test scripts/windows-concurrency-tests.test.mjs
|
||||
node scripts/windows-concurrency-tests.mjs "$RUNNER_TEMP/concurrency-windows.jsonl"
|
||||
|
||||
- name: upload Windows concurrency inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: concurrency-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/concurrency-windows.jsonl
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: test
|
||||
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
|
||||
env:
|
||||
@@ -196,35 +179,6 @@ jobs:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test ./...
|
||||
|
||||
- name: test (Windows subagent status admission)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^TestSubagent(PersistedStatusAdmission|UnknownStatusStillReadableForDiagnosis)$' ./internal/runtime/delegation | tee "$RUNNER_TEMP/subagent-status-admission.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
["future-paused", "missing", "empty", "COMPLETED", "completed", "cancelled", "failed", "interrupted", "running"] as $states |
|
||||
["continue", "copy", "ancestor", "legacy_ancestor", "current_copy"] as $routes |
|
||||
(["TestSubagentPersistedStatusAdmission", "TestSubagentUnknownStatusStillReadableForDiagnosis"] +
|
||||
[$states[] | "TestSubagentPersistedStatusAdmission/" + .] +
|
||||
[$states[] as $state | $routes[] | "TestSubagentPersistedStatusAdmission/" + $state + "/" + .]) as $required |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/runtime/delegation"] - $packages | length) == 0 and
|
||||
($required - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/subagent-status-admission.jsonl"
|
||||
|
||||
- name: upload Windows subagent status admission inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: subagent-status-admission-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/subagent-status-admission.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows composition links)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
@@ -301,40 +255,21 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -p 4 -timeout=18m -json ./internal/runtime/isolation/... ./internal/platform/worktree/... ./internal/state/mutation/... | tee "$RUNNER_TEMP/isolation-owners.jsonl" |
|
||||
go test -count=1 -p 4 -timeout=18m -json ./internal/platform/worktree/... ./internal/state/mutation/... | tee "$RUNNER_TEMP/isolation-owners.jsonl" |
|
||||
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $passed |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
(["reasonix/internal/runtime/isolation", "reasonix/internal/platform/worktree", "reasonix/internal/state/mutation"] - $passed | length) == 0 and
|
||||
(["TestResultOperationUsesCrossProcessOwnership",
|
||||
"TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/restore",
|
||||
"TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/remove",
|
||||
"TestApplyPathsWithUntrustedModesPreservesTheIndex/content",
|
||||
"TestApplyPathsWithUntrustedModesPreservesTheIndex/source_index_mode",
|
||||
"TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_mode",
|
||||
"TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_type",
|
||||
"TestApplyPathsEmulatedLinksUseSnapshotFilters",
|
||||
"TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings",
|
||||
(["reasonix/internal/platform/worktree", "reasonix/internal/state/mutation"] - $passed | length) == 0 and
|
||||
(["TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings",
|
||||
"TestLegacyWaitDoesNotConsumeExecutionCapacity"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/isolation-owners.jsonl"
|
||||
jq -se '
|
||||
. as $events |
|
||||
{"reasonix/internal/platform/worktree": [
|
||||
"TestApplyPathsRejectsRedirectedAncestors",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/outside_delete",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/inside_delete",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/dangling_delete",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/outside_write",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/inside_write",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/regular_parent",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/unchanged",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/missing_creation",
|
||||
"TestWriteChangesRejectsRedirectedDeletion",
|
||||
"TestWriteChangesUnlinksLeafDirectoryLink",
|
||||
"TestWholeWorkspaceApplyPreservesFileToDirectoryTransition"],
|
||||
"reasonix/internal/runtime/isolation": [
|
||||
"TestApplyToolRetainsResultAfterAncestorReplacement"]} |
|
||||
"TestWriteChangesUnlinksLeafDirectoryLink"
|
||||
]} |
|
||||
to_entries | all(.[]; . as $package |
|
||||
([ $events[] | select(.Package == $package.key and .Test == null and
|
||||
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
|
||||
@@ -354,60 +289,15 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows background resource owners)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 25
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -p 2 -timeout=18m -json ./internal/tools/jobs/... ./internal/runtime/delegation/... | tee "$RUNNER_TEMP/background-resources.jsonl" |
|
||||
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
(["reasonix/internal/tools/jobs", "reasonix/internal/runtime/delegation"] - $packages | length) == 0 and
|
||||
(["TestJobFinalizationCoversInvokedAndUninvoked/success",
|
||||
"TestJobFinalizationCoversInvokedAndUninvoked/error",
|
||||
"TestJobFinalizationCoversInvokedAndUninvoked/panic",
|
||||
"TestJobFinalizationCoversInvokedAndUninvoked/closed",
|
||||
"TestJobFinalizationCoversInvokedAndUninvoked/invalid_session",
|
||||
"TestJobFinalizationCoversInvokedAndUninvoked/invalid_kind",
|
||||
"TestJobFinalizationFailuresAttemptAllCallbacksAndKeepCauses/invoked",
|
||||
"TestJobFinalizationFailuresAttemptAllCallbacksAndKeepCauses/uninvoked",
|
||||
"TestJobFinalizationPrecedesCompletionPublication",
|
||||
"TestJobFinalizationCloseBetweenPublicationAndLaunch",
|
||||
"TestJobFinalizationWaitsForClosureAfterLogicalCancellation",
|
||||
"TestBackgroundNoLaunchFinalizesOwnedResources/task/closed_manager",
|
||||
"TestBackgroundNoLaunchFinalizesOwnedResources/task/invalid_session",
|
||||
"TestBackgroundNoLaunchFinalizesOwnedResources/fleet/closed_manager",
|
||||
"TestBackgroundNoLaunchFinalizesOwnedResources/fleet/invalid_session"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/background-resources.jsonl"
|
||||
|
||||
- name: upload Windows background resource inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: background-resources-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/background-resources.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows delegated Goal usage scope)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(UsageScope.*|DelegatedUsageKeepsGoalOwner|EffectDelegatedUsageKeepsGoalOwner)$' ./internal/contract/event ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/goal-usage-scope.jsonl"
|
||||
go test -count=1 -timeout=3m -json -run '^Test(UsageScope.*)$' ./internal/contract/event ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/goal-usage-scope.jsonl"
|
||||
jq -se '
|
||||
["TestUsageScopePreservesCapturedOwnerAndWire", "TestUsageScopeKeepsEarlierEventOwner",
|
||||
"TestUsageScopeWithoutCapability", "TestDelegatedUsageKeepsGoalOwner",
|
||||
"TestDelegatedUsageKeepsGoalOwner/chat_to_goal", "TestDelegatedUsageKeepsGoalOwner/replaced_goal",
|
||||
"TestDelegatedUsageKeepsGoalOwner/cleared_goal", "TestDelegatedUsageKeepsGoalOwner/resumed_goal",
|
||||
"TestDelegatedUsageKeepsGoalOwner/same_goal_next_turn", "TestDelegatedUsageKeepsGoalOwner/between_turns",
|
||||
"TestDelegatedUsageKeepsGoalOwner/nested_after_replacement", "TestEffectDelegatedUsageKeepsGoalOwner",
|
||||
"TestEffectDelegatedUsageKeepsGoalOwner/chat_to_goal", "TestEffectDelegatedUsageKeepsGoalOwner/replaced_goal",
|
||||
"TestEffectDelegatedUsageKeepsGoalOwner/nested_chat_to_goal", "TestEffectDelegatedUsageKeepsGoalOwner/same_goal",
|
||||
"TestEffectDelegatedUsageKeepsGoalOwner/completed_before_goal"] as $required |
|
||||
["TestUsageScopeKeepsEarlierEventOwner",
|
||||
"TestUsageScopeWithoutCapability" ] as $required |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
all(.[]; .Action != "fail" and .Action != "skip") and
|
||||
@@ -424,60 +314,19 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows planner local budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^TestEffectPlannerConfiguredTaskBudget$' ./internal/assembly/boot | tee "$RUNNER_TEMP/planner-local-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestEffectPlannerConfiguredTaskBudget", "TestEffectPlannerConfiguredTaskBudget/tokens",
|
||||
"TestEffectPlannerConfiguredTaskBudget/cost", "TestEffectPlannerConfiguredTaskBudget/wall",
|
||||
"TestEffectPlannerConfiguredTaskBudget/unconfigured", "TestEffectPlannerConfiguredTaskBudget/explicit_zero",
|
||||
"TestEffectPlannerConfiguredTaskBudget/unpriced_cost", "TestEffectPlannerConfiguredTaskBudget/missing_usage_cost",
|
||||
"TestEffectPlannerConfiguredTaskBudget/context_bound", "TestEffectPlannerConfiguredTaskBudget/context_disables",
|
||||
"TestEffectPlannerConfiguredTaskBudget/executor_only"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/planner-local-budget.jsonl"
|
||||
|
||||
- name: upload Windows planner local budget inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: planner-local-budget-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/planner-local-budget.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows Controller routing root budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(SemanticRootUsage|EffectRoutingRootBudget)$' ./internal/runtime/capability ./internal/assembly/boot | tee "$RUNNER_TEMP/controller-routing-budget.jsonl"
|
||||
go test -count=1 -timeout=3m -json -run '^Test(SemanticRootUsage)$' ./internal/runtime/capability | tee "$RUNNER_TEMP/controller-routing-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/runtime/capability", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["reasonix/internal/runtime/capability"] - $packages | length) == 0 and
|
||||
([
|
||||
"TestEffectRoutingRootBudget",
|
||||
"TestEffectRoutingRootBudget/balanced",
|
||||
"TestEffectRoutingRootBudget/cached_continuation",
|
||||
"TestEffectRoutingRootBudget/cached_new_scope",
|
||||
"TestEffectRoutingRootBudget/cached_new_task",
|
||||
"TestEffectRoutingRootBudget/cached_reset",
|
||||
"TestEffectRoutingRootBudget/context_zero",
|
||||
"TestEffectRoutingRootBudget/dual",
|
||||
"TestEffectRoutingRootBudget/headless",
|
||||
"TestEffectRoutingRootBudget/interactive",
|
||||
"TestEffectRoutingRootBudget/missing",
|
||||
"TestEffectRoutingRootBudget/unconfigured",
|
||||
"TestSemanticRootUsage",
|
||||
"TestSemanticRootUsage/billed_error",
|
||||
"TestSemanticRootUsage/cache",
|
||||
@@ -505,22 +354,17 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(PreparedRootBudget|CoordinatorRootBudget|EffectCoordinatorRoot)' ./internal/runtime/agent ./internal/runtime/coordinator ./internal/assembly/boot | tee "$RUNNER_TEMP/coordinator-root-budget.jsonl"
|
||||
go test -count=1 -timeout=3m -json -run '^Test(PreparedRootBudget|CoordinatorRootBudget|EffectCoordinatorRoot)' ./internal/runtime/agent ./internal/runtime/coordinator | tee "$RUNNER_TEMP/coordinator-root-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/runtime/agent", "reasonix/internal/runtime/coordinator", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestPreparedRootBudgetLifetime", "TestPreparedRootBudgetLifetime/continuation",
|
||||
"TestPreparedRootBudgetLifetime/new_scope", "TestPreparedRootBudgetLifetime/new_task",
|
||||
"TestPreparedRootBudgetLifetime/reset", "TestPreparedRootBudgetLifetime/new_session",
|
||||
"TestPreparedRootBudgetProjectionAndPreservedTurn", "TestCoordinatorRootBudgetRoutes",
|
||||
(["reasonix/internal/runtime/agent", "reasonix/internal/runtime/coordinator"] - $packages | length) == 0 and
|
||||
(["TestCoordinatorRootBudgetRoutes",
|
||||
"TestCoordinatorRootBudgetRoutes/raw", "TestCoordinatorRootBudgetRoutes/tool_planner",
|
||||
"TestCoordinatorRootBudgetRoutes/failure_fallback", "TestCoordinatorRootBudgetRoutes/plan_only_continuation",
|
||||
"TestCoordinatorRootBudgetRoutes/approval_declined_continuation", "TestCoordinatorRootBudgetRoutes/approval_allowed",
|
||||
"TestEffectCoordinatorRootTokenBudget", "TestEffectCoordinatorRootTokenBudget/shared",
|
||||
"TestEffectCoordinatorRootTokenBudget/unconfigured", "TestEffectCoordinatorRootTokenBudget/context_disables",
|
||||
"TestEffectCoordinatorRootTokenBudget/missing_planner", "TestEffectCoordinatorRootTokenBudget/executor_only"] - $tests | length) == 0
|
||||
"TestCoordinatorRootBudgetRoutes/approval_declined_continuation", "TestCoordinatorRootBudgetRoutes/approval_allowed"
|
||||
] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/coordinator-root-budget.jsonl"
|
||||
|
||||
- name: upload Windows Coordinator root budget inventory
|
||||
@@ -578,10 +422,8 @@ jobs:
|
||||
"TestRootWallBudgetReachesDelegatedAgents/context", "TestRootWallBudgetReachesDelegatedAgents/unconfigured",
|
||||
"TestRootWallBudgetReachesDelegatedAgents/explicit_zero", "TestRootWallBudgetContinuationAndReset",
|
||||
"TestRootWallBudgetContinuationAndReset/continuation", "TestRootWallBudgetContinuationAndReset/new_task",
|
||||
"TestRootWallBudgetContinuationAndReset/reset", "TestRootWallBudgetContinuationAndReset/override",
|
||||
"TestRootWallBudgetProjectionKeepsJobDeadline", "TestEffectRootWallBudgetReachesManagedJobs",
|
||||
"TestEffectRootWallBudgetReachesManagedJobs/task", "TestEffectRootWallBudgetReachesManagedJobs/capability",
|
||||
"TestEffectRootWallBudgetReachesManagedJobs/fleet"] - $tests | length) == 0
|
||||
"TestRootWallBudgetContinuationAndReset/reset", "TestRootWallBudgetContinuationAndReset/override"
|
||||
] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/root-wall-budget.jsonl"
|
||||
|
||||
- name: upload Windows root wall budget inventory
|
||||
@@ -599,22 +441,19 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(RootToken|EffectRootToken|BudgetCountsKnownReportedTokens|ReportedToken|EffectReportedTotalTokenBudget)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-token-budget.jsonl"
|
||||
go test -count=1 -timeout=3m -json -run '^Test(RootToken|EffectRootToken|BudgetCountsKnownReportedTokens|ReportedToken)' ./internal/runtime/agent | tee "$RUNNER_TEMP/root-token-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["reasonix/internal/runtime/agent"] - $packages | length) == 0 and
|
||||
(["TestRootTokenBudgetIncludesChildProviderUsage", "TestRootTokenBudgetIncludesChildProviderUsage/configured",
|
||||
"TestRootTokenBudgetIncludesChildProviderUsage/context", "TestRootTokenBudgetIncludesChildProviderUsage/unconfigured",
|
||||
"TestRootTokenBudgetIncludesChildProviderUsage/explicit_zero", "TestRootTokenBudgetSurvivesNestedAndConcurrentChildren",
|
||||
"TestRootTokenBudgetSurvivesNestedAndConcurrentChildren/nested", "TestRootTokenBudgetSurvivesNestedAndConcurrentChildren/siblings",
|
||||
"TestRootTokenBudgetSurvivesNestedAndConcurrentChildren/child_override", "TestRootTokenBudgetContinuationAndReset",
|
||||
"TestRootTokenBudgetContinuationAndReset/continuation", "TestRootTokenBudgetContinuationAndReset/new_task",
|
||||
"TestRootTokenBudgetContinuationAndReset/reset", "TestRootTokenScopeAtomicAccountingAndJobCancellation",
|
||||
"TestEffectRootTokenBudgetReachesDelegatedJobs", "TestEffectRootTokenBudgetReachesDelegatedJobs/foreground",
|
||||
"TestEffectRootTokenBudgetReachesDelegatedJobs/foreground_capability", "TestEffectRootTokenBudgetReachesDelegatedJobs/background",
|
||||
"TestEffectRootTokenBudgetReachesDelegatedJobs/background_capability", "TestEffectRootTokenBudgetReachesDelegatedJobs/background_fleet"] - $tests | length) == 0
|
||||
"TestRootTokenBudgetContinuationAndReset/reset" ] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/root-token-budget.jsonl"
|
||||
jq -se '
|
||||
. as $events |
|
||||
@@ -628,17 +467,7 @@ jobs:
|
||||
"TestBudgetCountsKnownReportedTokens/negative_split",
|
||||
"TestBudgetCountsKnownReportedTokens/zero",
|
||||
"TestBudgetCountsKnownReportedTokens/negative",
|
||||
"TestReportedTokensEnforceLocalCapOnInheritedRoot",
|
||||
"TestReportedTokensFollowOriginalAuxiliaryRoot",
|
||||
"TestReportedTokenCountersSaturate"],
|
||||
"reasonix/internal/assembly/boot": [
|
||||
"TestEffectReportedTotalTokenBudget",
|
||||
"TestEffectReportedTotalTokenBudget/split",
|
||||
"TestEffectReportedTotalTokenBudget/total_only",
|
||||
"TestEffectReportedTotalTokenBudget/partial_split",
|
||||
"TestEffectReportedTotalTokenBudget/lower_total",
|
||||
"TestEffectReportedTotalTokenBudget/missing_total",
|
||||
"TestEffectReportedTotalTokenBudget/disabled"]} |
|
||||
"TestReportedTokenCountersSaturate"]} |
|
||||
to_entries | all(.[]; . as $package |
|
||||
([ $events[] | select(.Package == $package.key and .Test == null and
|
||||
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
|
||||
@@ -697,7 +526,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(HostStorageEnvironment|HostObjectStorage|HostExplicitStorageControls|DetachedStorageEnvironment|WorktreePreparationOwnsItsIndex|EffectIsolatedTaskOwnsGitStorage)$' ./internal/platform/gitcmd ./internal/platform/worktree ./internal/assembly/boot | tee "$RUNNER_TEMP/git-storage-environment.jsonl"
|
||||
go test -count=1 -timeout=3m -json -run '^Test(HostStorageEnvironment|HostObjectStorage|HostExplicitStorageControls|DetachedStorageEnvironment)$' ./internal/platform/gitcmd ./internal/platform/worktree ./internal/assembly/boot | tee "$RUNNER_TEMP/git-storage-environment.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
@@ -707,10 +536,6 @@ jobs:
|
||||
"TestDetachedStorageEnvironment",
|
||||
"TestDetachedStorageEnvironment/explicit_env",
|
||||
"TestDetachedStorageEnvironment/inherited_env",
|
||||
"TestEffectIsolatedTaskOwnsGitStorage",
|
||||
"TestEffectIsolatedTaskOwnsGitStorage/ambient_existing",
|
||||
"TestEffectIsolatedTaskOwnsGitStorage/ambient_missing",
|
||||
"TestEffectIsolatedTaskOwnsGitStorage/control",
|
||||
"TestHostExplicitStorageControls",
|
||||
"TestHostExplicitStorageControls/explicit_object_directory",
|
||||
"TestHostExplicitStorageControls/private_index",
|
||||
@@ -721,15 +546,8 @@ jobs:
|
||||
"TestHostStorageEnvironment",
|
||||
"TestHostStorageEnvironment/GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
"TestHostStorageEnvironment/GIT_INDEX_FILE",
|
||||
"TestHostStorageEnvironment/GIT_OBJECT_DIRECTORY",
|
||||
"TestWorktreePreparationOwnsItsIndex",
|
||||
"TestWorktreePreparationOwnsItsIndex/candidate_ambient_existing",
|
||||
"TestWorktreePreparationOwnsItsIndex/candidate_ambient_missing",
|
||||
"TestWorktreePreparationOwnsItsIndex/candidate_control",
|
||||
"TestWorktreePreparationOwnsItsIndex/delivery_ambient_existing",
|
||||
"TestWorktreePreparationOwnsItsIndex/delivery_ambient_missing",
|
||||
"TestWorktreePreparationOwnsItsIndex/delivery_control"
|
||||
] - $tests | length) == 0
|
||||
"TestHostStorageEnvironment/GIT_OBJECT_DIRECTORY"
|
||||
] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/git-storage-environment.jsonl"
|
||||
|
||||
- name: upload Windows Git storage environment inventory
|
||||
@@ -779,20 +597,19 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(RootWebSearch|EffectRootWebSearch)' ./internal/tools/websearch ./internal/assembly/boot | tee "$RUNNER_TEMP/root-websearch-budget.jsonl"
|
||||
go test -count=1 -timeout=3m -json -run '^Test(RootWebSearch|EffectRootWebSearch)' ./internal/tools/websearch | tee "$RUNNER_TEMP/root-websearch-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/tools/websearch", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["reasonix/internal/tools/websearch"] - $packages | length) == 0 and
|
||||
(["TestRootWebSearchUsageTerminal", "TestRootWebSearchUsageTerminal/reported",
|
||||
"TestRootWebSearchUsageTerminal/no_reporter", "TestRootWebSearchUsageTerminal/missing",
|
||||
"TestRootWebSearchUsageTerminal/no_observer", "TestRootWebSearchUsageTerminal/input_refused",
|
||||
"TestRootWebSearchUsageTerminal/no_factory", "TestRootWebSearchUsageTerminal/factory_error",
|
||||
"TestRootWebSearchUsageTerminal/billed_failure", "TestRootWebSearchUsageTerminal/cancel_after_usage",
|
||||
"TestRootWebSearchUsageTerminal/cancel_before_start", "TestRootWebSearchUsageTerminal/stream_error",
|
||||
"TestEffectRootWebSearchTokenBudget", "TestEffectRootWebSearchTokenBudget/reported",
|
||||
"TestEffectRootWebSearchTokenBudget/disabled", "TestEffectRootWebSearchTokenBudget/missing"] - $tests | length) == 0
|
||||
"TestRootWebSearchUsageTerminal/cancel_before_start", "TestRootWebSearchUsageTerminal/stream_error"
|
||||
] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/root-websearch-budget.jsonl"
|
||||
|
||||
- name: upload Windows root websearch budget inventory
|
||||
@@ -810,21 +627,13 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^(TestProfileRootBudgetLifetime|TestProfileBudgetPreparationUsesTurnAdmission|TestEffectExplicitSkillRootBudget)$' ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
|
||||
go test -count=1 -timeout=3m -json -run '^(TestProfileRootBudgetLifetime|TestProfileBudgetPreparationUsesTurnAdmission|TestEffectExplicitSkillRootBudget)$' ./internal/session/control | tee "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestProfileRootBudgetLifetime", "TestProfileRootBudgetLifetime/new_task",
|
||||
"TestProfileRootBudgetLifetime/same_scope", "TestProfileRootBudgetLifetime/new_scope",
|
||||
"TestProfileRootBudgetLifetime/reset", "TestProfileRootBudgetLifetime/new_session",
|
||||
"TestProfileRootBudgetLifetime/inherited", "TestProfileRootBudgetLifetime/context_zero",
|
||||
"TestProfileBudgetPreparationUsesTurnAdmission", "TestEffectExplicitSkillRootBudget",
|
||||
"TestEffectExplicitSkillRootBudget/profile", "TestEffectExplicitSkillRootBudget/preview",
|
||||
"TestEffectExplicitSkillRootBudget/slash", "TestEffectExplicitSkillRootBudget/chips",
|
||||
"TestEffectExplicitSkillRootBudget/goal", "TestEffectExplicitSkillRootBudget/context_zero",
|
||||
"TestEffectExplicitSkillRootBudget/unconfigured"] - $tests | length) == 0
|
||||
(["reasonix/internal/session/control"] - $packages | length) == 0 and
|
||||
(["TestProfileBudgetPreparationUsesTurnAdmission" ] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
|
||||
|
||||
- name: upload Windows explicit skill root budget inventory
|
||||
@@ -842,19 +651,16 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(RootTriage|EffectRootTriage)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-triage-budget.jsonl"
|
||||
go test -count=1 -timeout=3m -json -run '^Test(RootTriage|EffectRootTriage)' ./internal/runtime/agent | tee "$RUNNER_TEMP/root-triage-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["reasonix/internal/runtime/agent"] - $packages | length) == 0 and
|
||||
(["TestRootTriageTokenBudget", "TestRootTriageTokenBudget/classifier", "TestRootTriageTokenBudget/screen",
|
||||
"TestRootTriageTokenBudget/billed_failure", "TestRootTriageTokenBudget/missing", "TestRootTriageTokenBudget/disabled",
|
||||
"TestRootTriageTokenBudget/no_sink", "TestRootTriageTokenBudget/counts_without_total",
|
||||
"TestRootTriageOutsideRunDoesNotCreateScope", "TestEffectRootTriageScreenBudget",
|
||||
"TestEffectRootTriageScreenBudget/reported", "TestEffectRootTriageScreenBudget/flagged",
|
||||
"TestEffectRootTriageScreenBudget/disabled", "TestEffectRootTriageScreenBudget/missing",
|
||||
"TestEffectRootTriageScreenBudget/screen_disabled"] - $tests | length) == 0
|
||||
"TestRootTriageOutsideRunDoesNotCreateScope" ] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/root-triage-budget.jsonl"
|
||||
|
||||
- name: upload Windows root triage budget inventory
|
||||
@@ -866,54 +672,22 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows root auxiliary budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(RootAuxiliary|EffectRootAuxiliary)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-auxiliary-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestRootAuxiliaryAutomaticCompaction", "TestRootAuxiliaryTokenBudget",
|
||||
"TestRootAuxiliaryTokenBudget/summary", "TestRootAuxiliaryTokenBudget/summary_failed",
|
||||
"TestRootAuxiliaryTokenBudget/summary_missing", "TestRootAuxiliaryTokenBudget/summary_disabled",
|
||||
"TestRootAuxiliaryTokenBudget/diagnosis", "TestRootAuxiliaryTokenBudget/diagnosis_failed",
|
||||
"TestRootAuxiliaryTokenBudget/diagnosis_missing", "TestRootAuxiliaryTokenBudget/diagnosis_disabled",
|
||||
"TestRootAuxiliaryManualCompactionOutsideRun", "TestRootAuxiliaryBudgetProjectionKeepsOldTask",
|
||||
"TestEffectRootAuxiliarySummaryBudget", "TestEffectRootAuxiliarySummaryBudget/configured",
|
||||
"TestEffectRootAuxiliarySummaryBudget/disabled", "TestEffectRootAuxiliarySummaryBudget/missing_usage"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/root-auxiliary-budget.jsonl"
|
||||
|
||||
- name: upload Windows root auxiliary budget inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: root-auxiliary-budget-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/root-auxiliary-budget.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows local cost quote)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(LocalBudgetUsesOccurrenceQuote|LocalCost.*|UsageQuote.*|EffectLocalCostUsesOccurrenceQuote)$' ./internal/contract/event ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/local-cost-quote.jsonl"
|
||||
go test -count=1 -timeout=3m -json -run '^Test(LocalBudgetUsesOccurrenceQuote|LocalCost.*|UsageQuote.*)$' ./internal/contract/event ./internal/runtime/agent | tee "$RUNNER_TEMP/local-cost-quote.jsonl"
|
||||
jq -se '
|
||||
["TestLocalBudgetUsesOccurrenceQuote", "TestLocalBudgetUsesOccurrenceQuote/peak", "TestLocalBudgetUsesOccurrenceQuote/offpeak",
|
||||
"TestLocalCostKeepsOriginalCurrencyBuckets", "TestLocalCostFixedPointThresholdAndUnknown",
|
||||
"TestUsageQuoteUsesOneOwnerAndOccurrence", "TestUsageQuoteLegacyAndNilSinks",
|
||||
"TestEffectLocalCostUsesOccurrenceQuote", "TestEffectLocalCostUsesOccurrenceQuote/peak",
|
||||
"TestEffectLocalCostUsesOccurrenceQuote/offpeak", "TestEffectLocalCostUsesOccurrenceQuote/relay"] as $required |
|
||||
"TestUsageQuoteUsesOneOwnerAndOccurrence", "TestUsageQuoteLegacyAndNilSinks"
|
||||
] as $required |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
all(.[]; .Action != "fail" and .Action != "skip") and
|
||||
(($required - $passed) | length == 0) and
|
||||
((["reasonix/internal/contract/event", "reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages) | length == 0)
|
||||
((["reasonix/internal/contract/event", "reasonix/internal/runtime/agent"] - $packages) | length == 0)
|
||||
' "$RUNNER_TEMP/local-cost-quote.jsonl"
|
||||
|
||||
- name: upload Windows local cost quote inventory
|
||||
@@ -963,24 +737,18 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(AuxiliaryUsageObserver|BoundedUsageObserver|RootBounded|EffectRootBounded)' ./internal/contract/provider ./internal/model/boundedllm ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/bounded-root-budget.jsonl"
|
||||
go test -count=1 -timeout=3m -json -run '^Test(AuxiliaryUsageObserver|BoundedUsageObserver|RootBounded|EffectRootBounded)' ./internal/contract/provider ./internal/model/boundedllm ./internal/runtime/agent | tee "$RUNNER_TEMP/bounded-root-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/contract/provider", "reasonix/internal/model/boundedllm",
|
||||
"reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
"reasonix/internal/runtime/agent"] - $packages | length) == 0 and
|
||||
(["TestAuxiliaryUsageObserverReplacesAndCopies",
|
||||
"TestBoundedUsageObserverOwnsOneTerminal", "TestBoundedUsageObserverOwnsOneTerminal/reported",
|
||||
"TestBoundedUsageObserverOwnsOneTerminal/billed_failure", "TestBoundedUsageObserverOwnsOneTerminal/without_sink",
|
||||
"TestBoundedUsageObserverOwnsOneTerminal/missing", "TestBoundedUsageObserverOwnsOneTerminal/input_refused",
|
||||
"TestRootBoundedReviewerBudget", "TestRootBoundedReviewerBudget/reported",
|
||||
"TestRootBoundedReviewerBudget/billed_failure", "TestRootBoundedReviewerBudget/missing_usage",
|
||||
"TestRootBoundedReviewerBudget/disabled", "TestRootBoundedReviewerBudget/no_emission",
|
||||
"TestRootBoundedReviewerBudget/projected_job_context", "TestRootBoundedNestedBudgetChargesOnce",
|
||||
"TestRootBoundedLateProjectionStaysWithOldTask", "TestEffectRootBoundedAdvisorBudget",
|
||||
"TestEffectRootBoundedAdvisorBudget/reported", "TestEffectRootBoundedAdvisorBudget/disabled",
|
||||
"TestEffectRootBoundedAdvisorBudget/missing_usage"] - $tests | length) == 0
|
||||
"TestBoundedUsageObserverOwnsOneTerminal/missing", "TestBoundedUsageObserverOwnsOneTerminal/input_refused"
|
||||
] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/bounded-root-budget.jsonl"
|
||||
|
||||
- name: upload Windows bounded root budget inventory
|
||||
@@ -1009,42 +777,6 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
|
||||
|
||||
# Full push suite: a 36m package timeout inside a 45m step budget. -count=1
|
||||
# as at the smoke step. A failure prints the failed tests' output and the 15
|
||||
# slowest boot tests.
|
||||
- name: test (full)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
# built around.
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
# Bound sandbox helper children in Windows tests so a failed OS-level
|
||||
# launch cannot pin the Actions step after Go's package timeout fires.
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
shell: bash
|
||||
run: |
|
||||
# internal/assembly/boot alone can take over 25 minutes on a Windows runner;
|
||||
# the package limit leaves headroom inside the step limit above.
|
||||
go test -count=1 -p 4 -timeout=36m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
|
||||
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
|
||||
|
||||
- name: test (full) failure detail
|
||||
if: failure() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
|
||||
shell: bash
|
||||
run: |
|
||||
f="$RUNNER_TEMP/full.jsonl"
|
||||
[ -s "$f" ] || exit 0
|
||||
echo "::group::output of failed tests"
|
||||
jq -rn '[inputs | select(.Test != null)] | group_by(.Package + "/" + .Test)
|
||||
| map(select(any(.[]; .Action == "fail")))[]
|
||||
| map(select(.Action == "output") | .Output) | join("")' "$f" || true
|
||||
echo "::endgroup::"
|
||||
echo "slowest internal/assembly/boot tests (seconds, name):"
|
||||
jq -rn '[inputs | select((.Package | endswith("/internal/assembly/boot")) and .Test != null and .Action == "pass")]
|
||||
| sort_by(-.Elapsed)[:15][] | "\(.Elapsed)\t\(.Test)"' "$f" || true
|
||||
|
||||
race:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
|
||||
@@ -1079,15 +811,130 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test -race ./internal/runtime/agent/... ./internal/runtime/delegation/... ./internal/runtime/isolation/... ./internal/platform/worktree/... ./internal/state/mutation/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
|
||||
run: go test -race ./internal/runtime/agent/... ./internal/runtime/delegation/... ./internal/platform/worktree/... ./internal/state/mutation/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
|
||||
|
||||
- name: test -race (full)
|
||||
if: github.event_name != 'pull_request'
|
||||
# Push-only full suites. internal/frontend/serve and internal/assembly/boot run
|
||||
# hundreds of serial tests and set the wall time of one whole-tree `go test`,
|
||||
# so each is cut by test name into shards that run on their own runners. The
|
||||
# -run / -skip patterns partition the names exactly: every test lands in one
|
||||
# shard. `rest` is every other package. Pull requests keep the focused steps
|
||||
# in the `test` and `race` jobs above.
|
||||
test-windows-shards:
|
||||
if: github.event_name != 'pull_request'
|
||||
name: test (windows-latest, ${{ matrix.shard }})
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { shard: rest }
|
||||
- { shard: serve-1, pkg: ./internal/frontend/serve, flag: -run, filter: '^Test[A-E]' }
|
||||
- { shard: serve-2, pkg: ./internal/frontend/serve, flag: -run, filter: '^Test[F-R]' }
|
||||
- { shard: serve-3, pkg: ./internal/frontend/serve, flag: -skip, filter: '^Test[A-R]' }
|
||||
- { shard: boot-1, pkg: ./internal/assembly/boot, flag: -skip, filter: '^TestEffect([^0-9A-C]|$)' }
|
||||
- { shard: boot-2, pkg: ./internal/assembly/boot, flag: -run, filter: '^TestEffect[D-O]' }
|
||||
- { shard: boot-3, pkg: ./internal/assembly/boot, flag: -run, filter: '^TestEffect([^0-9A-O]|$)' }
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: test (shard)
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
# built around.
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
# Bound sandbox helper children in Windows tests so a failed OS-level
|
||||
# launch cannot pin the Actions step after Go's package timeout fires.
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
SHARD: ${{ matrix.shard }}
|
||||
PKG: ${{ matrix.pkg }}
|
||||
FLAG: ${{ matrix.flag }}
|
||||
FILTER: ${{ matrix.filter }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$SHARD" = rest ]; then
|
||||
mapfile -t pkgs < <(go list ./... | tr -d '\r' | grep -v -E '/internal/(frontend/serve|assembly/boot)$')
|
||||
else
|
||||
pkgs=("$PKG")
|
||||
fi
|
||||
filter=()
|
||||
if [ -n "$FILTER" ]; then filter=("$FLAG" "$FILTER"); fi
|
||||
go test -count=1 -p 4 -timeout=36m -json "${filter[@]}" "${pkgs[@]}" | tee "$RUNNER_TEMP/shard.jsonl" |
|
||||
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
|
||||
|
||||
- name: test (shard) failure detail
|
||||
if: failure()
|
||||
shell: bash
|
||||
run: |
|
||||
f="$RUNNER_TEMP/shard.jsonl"
|
||||
[ -s "$f" ] || exit 0
|
||||
echo "::group::output of failed tests"
|
||||
jq -rn '[inputs | select(.Test != null)] | group_by(.Package + "/" + .Test)
|
||||
| map(select(any(.[]; .Action == "fail")))[]
|
||||
| map(select(.Action == "output") | .Output) | join("")' "$f" || true
|
||||
echo "::endgroup::"
|
||||
echo "slowest tests (seconds, package, name):"
|
||||
jq -rn '[inputs | select(.Test != null and .Action == "pass")]
|
||||
| sort_by(-.Elapsed)[:15][] | "\(.Elapsed) \(.Package) \(.Test)"' "$f" || true
|
||||
|
||||
race-shards:
|
||||
if: github.event_name != 'pull_request'
|
||||
name: race (${{ matrix.shard }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { shard: rest }
|
||||
- { shard: serve-1, pkg: ./internal/frontend/serve, flag: -run, filter: '^Test[A-E]' }
|
||||
- { shard: serve-2, pkg: ./internal/frontend/serve, flag: -run, filter: '^Test[F-R]' }
|
||||
- { shard: serve-3, pkg: ./internal/frontend/serve, flag: -skip, filter: '^Test[A-R]' }
|
||||
- { shard: boot-1, pkg: ./internal/assembly/boot, flag: -skip, filter: '^TestEffect([^0-9A-C]|$)' }
|
||||
- { shard: boot-2, pkg: ./internal/assembly/boot, flag: -run, filter: '^TestEffect[D-O]' }
|
||||
- { shard: boot-3, pkg: ./internal/assembly/boot, flag: -run, filter: '^TestEffect([^0-9A-O]|$)' }
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Install and verify Linux sandbox backend
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y bubblewrap
|
||||
if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
|
||||
sudo sysctl -w kernel.unprivileged_userns_clone=1
|
||||
fi
|
||||
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
fi
|
||||
bwrap --ro-bind / / --dev /dev --proc /proc -- true || echo "::warning::bubblewrap is installed but this runner denies user namespaces; unavailable-backend tests will run fail-closed"
|
||||
|
||||
- name: test -race (shard)
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
# internal/assembly/boot under -race runs past ten minutes on a shared
|
||||
# runner; the package limit leaves headroom over the default.
|
||||
run: go test -race -timeout=25m ./...
|
||||
SHARD: ${{ matrix.shard }}
|
||||
PKG: ${{ matrix.pkg }}
|
||||
FLAG: ${{ matrix.flag }}
|
||||
FILTER: ${{ matrix.filter }}
|
||||
run: |
|
||||
if [ "$SHARD" = rest ]; then
|
||||
mapfile -t pkgs < <(go list ./... | grep -v -E '/internal/(frontend/serve|assembly/boot)$')
|
||||
else
|
||||
pkgs=("$PKG")
|
||||
fi
|
||||
filter=()
|
||||
if [ -n "$FILTER" ]; then filter=("$FLAG" "$FILTER"); fi
|
||||
go test -race -timeout=25m "${filter[@]}" "${pkgs[@]}"
|
||||
|
||||
# sdk/go is a nested stdlib-only module invisible to root `go test ./...`.
|
||||
# Its DTOs are generated from internal/ext/extension/protocol, and the
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: PR author gate
|
||||
|
||||
# Pull requests are limited to maintainers and the logins in .github/pr-authors.txt.
|
||||
# This runs in the base repository's context, checks out the base branch only, and
|
||||
# never executes pull-request code; the allowlist it reads is the base branch's.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: pr-author-gate-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- run: node scripts/pr-author-gate.mjs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -16,7 +16,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: studio-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/studio' }}
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
@@ -188,6 +188,7 @@ jobs:
|
||||
node perf/web-access.mjs
|
||||
node perf/peers-settings.mjs
|
||||
node perf/output-cap.mjs
|
||||
node perf/tool-output-clip.mjs
|
||||
node perf/agent-run-tab.mjs
|
||||
node perf/changes-cap.mjs
|
||||
node perf/session-menu-fit.mjs
|
||||
|
||||
@@ -103,10 +103,6 @@ func newHarness(t *testingDir, p provider.Provider, window int, rec *callRecorde
|
||||
ContextWindow: window,
|
||||
ArchiveDir: filepath.Join(t.dir, "archive"),
|
||||
SessionPath: path,
|
||||
RecentKeep: 4,
|
||||
// boot's default when cfg.Agent.Keep is unset; without it the bench
|
||||
// would measure a configuration no real session runs.
|
||||
KeepPolicy: agent.KeepErrors,
|
||||
}, rec.sink())
|
||||
return &harness{sess: sess, agentA: a, path: path, calls: rec}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ func newAgent(p provider.Provider, sess *sessionstore.Session, path string, sink
|
||||
return agent.New(p, tool.NewRegistry(), sess, agent.Options{
|
||||
ContextWindow: windowTokens,
|
||||
CompactRatio: compactRatio,
|
||||
RecentKeep: 2,
|
||||
SessionPath: path,
|
||||
WorkspaceID: "cm-e2e",
|
||||
ModelRef: "deepseek/" + model,
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
# Context retrieval bench
|
||||
|
||||
Two questions about long-horizon memory, and the harness that makes their
|
||||
answers trustworthy.
|
||||
|
||||
-mode=preflight build all 18 tasks under every arm and check the contract
|
||||
-mode=snippet does a rank-1 hit hand over the whole answer?
|
||||
-mode=adversarial send a model after the answer through every host surface
|
||||
-mode=run-search Experiment R: is searchable canonical a real capability?
|
||||
-mode=run-boundary Experiment I, paired: the 6 efficiency tasks across their boundary
|
||||
-mode=run-index Experiment I, full: the 6 efficiency tasks x 4 budgets
|
||||
-mode=calibrate find each index task's PlantAfterGen for its cue tier
|
||||
|
||||
`-dry` drives any run mode with a scripted provider, for nothing. Real runs need
|
||||
`DEEPSEEK_API_KEY`.
|
||||
|
||||
## Status
|
||||
|
||||
**Searchable canonical recall: proven.** Six of six recovered with search on,
|
||||
three of six with it off, and the successes without it came from enumerating
|
||||
addresses by hand at roughly nine times the page-in cost (139 vs 1292
|
||||
tokens/task). Measured after three separate leaks were closed, so the numbers
|
||||
are from a clean environment.
|
||||
|
||||
**Fold index marginal utility: direction reproduced, size still conditional.** On the v2
|
||||
substrate the direction holds in six of six tasks and in the pooled paired batch: with the
|
||||
cue visible the index addresses the target, so the same task is searched less and pages in
|
||||
less (0.67 searches and 70 recall tokens per task, against 1.50 and 120 with the cue
|
||||
absent).
|
||||
|
||||
A size is visible but is not yet quotable. On the four tasks whose absent-cue arm stayed
|
||||
out of a loop, the index cost 64,952 prompt tokens against 115,551 without, 44% less
|
||||
(`i106-budgetseal` -35,147, `i70-ingestfloor` -8,222, `i37-dispatchlag` -7,599, and
|
||||
`i112-lagquota` +369, the prefix rent a task that never searches pays). That is a
|
||||
**post-hoc subset**: `i118-fencelag` and `i47-tenantlease` are excluded because their
|
||||
absent-cue arm looped in this batch (five and three retrieval rounds, three escapes each;
|
||||
together 80% of the absent-cue total), and the qualification above does not screen for
|
||||
stopping behaviour — holding on both boundary arms across three passes shows a stable cue
|
||||
boundary, not a stopping arm. Quoting 44% as the index's effect would mean choosing the
|
||||
sample after seeing the result; a batch screened on stopping stability is what would make
|
||||
a size quotable, and one arm also reverses either way (`i70-ingestfloor` default, +11,135
|
||||
prompt tokens over three escapes).
|
||||
|
||||
Batch facts, because the counts differ: 36 runs were executed (12 paired, 24 index), and
|
||||
the token totals here cover the 24-run index batch — the paired batch ran before the
|
||||
per-run usage line existed.
|
||||
|
||||
**Things that turned out not to be problems**, each after being measured rather
|
||||
than argued about:
|
||||
|
||||
| Suspected | Measured |
|
||||
| --- | --- |
|
||||
| Model writes poor queries (2.17 searches/task) | 22/23 found the target on query 1; the extra searches are one round's parallel fan-out |
|
||||
| Model reaches for the workspace before memory (7/11) | Linear event order misread; by model round it is MemoryFirst 4/6 |
|
||||
| Snippets too short for multi-value answers | 240 runes covers 12/12 tasks at 100%; records are 30-179 runes |
|
||||
| A general stopping failure | 37/44 runs issued no search after the answer was in hand; all 7 that did belong to 2 tasks |
|
||||
|
||||
## Measurement distinctions this bench had to learn
|
||||
|
||||
Each of these reversed a conclusion once. They are enforced in the schema now,
|
||||
not left to whoever reads the numbers next.
|
||||
|
||||
- **TargetHit is not EvidenceSufficient.** A rank-1 hit can return a window too
|
||||
short to answer with. `FirstHitCoverage` records how much of the scored answer
|
||||
the first hit actually handed over.
|
||||
- **Linear event order is not model-round order.** Two calls in one round are a
|
||||
parallel fan-out, not a preference. Routing compares rounds.
|
||||
- **An isolated workdir is not an isolated host.** The sandbox mounts the host
|
||||
read-only by design. Three leaks were found and closed: the corpus source, the
|
||||
fixture transcript, and its event log sidecar.
|
||||
- **A count is not its evidence.** Query text, snippet contents and trajectories
|
||||
are all persisted now, because three analyses in a row needed a paid re-run to
|
||||
ask a question of data already collected.
|
||||
|
||||
## How a run stays honest
|
||||
|
||||
The corpus holds templates, never answers: every scored literal is a `{{var}}`
|
||||
instantiated per run, so grepping this directory reveals the question and not
|
||||
the answer. `TestNoAnswerLiteralExistsInTheRepository` asserts it with
|
||||
`git grep`. Preflight rebuilds every arm and checks the answer is unreadable in
|
||||
the real `provider.Request`, the probe query ranks the target within five, and
|
||||
an index task's cue sits at exactly the scales its tier names. The fixture is
|
||||
removed from disk once the agent holds it in memory, and the directory is
|
||||
scanned for answer literals before the first provider request and again after
|
||||
the turn. Any answer reaching the model through a tool that is not `recall`
|
||||
marks the run contaminated and takes it out of every statistic.
|
||||
|
||||
## Index corpus v2
|
||||
|
||||
The efficiency substrate is six tasks that hold on both arms: `i37-dispatchlag`
|
||||
and `i118-fencelag` (quarter), `i106-budgetseal` and `i70-ingestfloor` (half),
|
||||
`i47-tenantlease` and `i112-lagquota` (default).
|
||||
|
||||
They came out of five batches of candidates, each screened the same way: one
|
||||
`-mode=run-index` pass over the batch, then two more only for the candidates that
|
||||
were clean on both boundary arms, and only the candidates that held on both arms
|
||||
in all three passes stayed. A candidate that does not hold is dropped, not
|
||||
promoted. The numbers are candidates, per tier:
|
||||
|
||||
| batch | quarter | half | default |
|
||||
| --- | --- | --- | --- |
|
||||
| first pool, `i31`-`i54` | 1 of 8 | 0 of 8 | 2 of 8 |
|
||||
| second pool, `i61`-`i84` | 1 of 4 screened | 4 of 8 | 0 of 8 |
|
||||
| third pool, `i85`-`i108` | 2 of 8 | 8 of 16 | none authored |
|
||||
| replacement pool, `i109`-`i116` | - | - | 4 of 8 |
|
||||
| replacement pool, `i92`, `i117`-`i124` | 3 of 9 | - | - |
|
||||
|
||||
That table counts candidates that held on both arms in all three passes, which is
|
||||
not the size of the substrate. 89 candidates were authored, 77 were screened (the
|
||||
second pool's quarter tier stopped at 4 of 8 and its default tier was never run),
|
||||
and 25 held: 7 quarter, 12 half, 6 default. The substrate keeps two per tier, so
|
||||
the other 19 survivors are spares rather than corpus members. Candidates from the
|
||||
earlier round are not counted here.
|
||||
|
||||
| task | tier | cue present | cue absent |
|
||||
| --- | --- | ---: | ---: |
|
||||
| `i37-dispatchlag` | quarter | 3/3 | 3/3 |
|
||||
| `i118-fencelag` | quarter | 3/3 | 3/3 |
|
||||
| `i106-budgetseal` | half | 3/3 | 3/3 |
|
||||
| `i70-ingestfloor` | half | 3/3 | 3/3 |
|
||||
| `i47-tenantlease` | default | 3/3 | 3/3 |
|
||||
| `i112-lagquota` | default | 3/3 | 3/3 |
|
||||
|
||||
Across their 36 judged cells: `SnippetStop` 23, `SnippetThenRead` 10 and
|
||||
`DefensiveRead` 3 - every cell clean, nothing post-sufficient and nothing
|
||||
`NeverSufficient`.
|
||||
|
||||
Four of the first six were flagged in review for sharing answer values, probe
|
||||
queries and prompt wording within a tier. Separating them and re-screening those
|
||||
four changed two of them: `i37` and `i47` held, `i62` and `i51` did not, so they
|
||||
were replaced - `i90-recoverylag` was tried first and did not hold either, and
|
||||
`i118-fencelag` and `i112-lagquota` are what did. The separation was not
|
||||
cosmetic: the shared wording had been carrying two of the six.
|
||||
|
||||
Nothing in the task shape separates the survivors from the rest: within the half
|
||||
tier, plain-number answers survived 0 of 8 in the first pool and 4 of 8 in the
|
||||
second, and codename answers survived 8 of 16 in the third. An earlier claim here
|
||||
that the answer kind decides whether a candidate holds does not survive this
|
||||
table and is withdrawn. The dirty class was `PostSufficientRetrieval` in most
|
||||
failures - the model re-checking a value it already had - but which tasks hit it
|
||||
was not predictable from their shape at this sample.
|
||||
|
||||
`i01`-`i06` are the frozen study's six tasks, kept as `roleStopping`. Measured
|
||||
once each with `-mode=run-index -task <id>`, per task and per arm:
|
||||
|
||||
| task | tier | cue present | cue absent |
|
||||
| --- | --- | --- | --- |
|
||||
| `i01-transport-fallback` | quarter | SnippetStop | DefensiveRead |
|
||||
| `i02-scheduler-handoff` | quarter | PostSufficientRetrieval | PostSufficientRetrieval |
|
||||
| `i03-coalescing` | half | PostSufficientRetrieval | PostSufficientRunaway |
|
||||
| `i04-recovery-fence` | half | DefensiveRead | PostSufficientRunaway |
|
||||
| `i05-cache-probe` | default | SnippetThenRead | PostSufficientRetrieval |
|
||||
| `i06-dispatch-handoff` | default | SnippetStop | PostSufficientRetrieval |
|
||||
|
||||
Five of the six show post-sufficient behaviour in at least one arm (`i02`-`i06`),
|
||||
and `i01` is clean on both. The cue-present arm is clean for four of them
|
||||
(`i01`, `i04`, `i05`, `i06`), which is why they stay in the corpus as the
|
||||
stopping side rather than being retitled. All six are excluded from `run-index`
|
||||
and `run-boundary` batches and stay measurable one at a time with `-task <id>`. A
|
||||
task added to the corpus declares a role, so it cannot arrive without one.
|
||||
|
||||
A task qualifies only if it is clean on both of its boundary arms - the
|
||||
cue-present run and the cue-absent run - in every run of a screening batch. Clean
|
||||
means a `stopping_class` other than `PostSufficientRetrieval` or
|
||||
`PostSufficientRunaway`: `SnippetStop`, `SnippetThenRead` and `DefensiveRead`
|
||||
all qualify, and a defensive read is deliberately not counted as waste. The
|
||||
class comes from the three counts in that report — `searches_after_sufficient`,
|
||||
`reads_after_sufficient` and `escapes_after_sufficient` — together with how
|
||||
sufficiency was reached and how many rounds followed it. The remaining
|
||||
requirements still apply — successful tool call, answer in the output rather
|
||||
than the cue, rank 1, `FirstHitCoverage` complete, one or two simple values,
|
||||
nothing in the workspace.
|
||||
|
||||
`-mode=calibrate` is not a screening tool: it builds and reads back the
|
||||
fixtures, and reads nothing else, so its cue-visibility profile is the same for
|
||||
every task.
|
||||
|
||||
Then repeats rather than levels: cue-present against cue-absent on each task's
|
||||
own boundary, three runs per cell. What is unknown is the variance, and four
|
||||
budgets sampled once each cannot show it.
|
||||
@@ -1,86 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// runBlockedBatch runs one dry index batch where every arm's runOne fails
|
||||
// before any model turn: the task's own directory is a regular file, so the
|
||||
// fixture cannot be built. In production the same early return happens when the
|
||||
// fixture build, the session load, or the answer-on-disk guard fails.
|
||||
func runBlockedBatch(t *testing.T) string {
|
||||
t.Helper()
|
||||
tk := indexTasks()[0]
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, tk.ID), []byte("not a directory\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := runExperiment("run-index", experimentIndex, root, true, []contextTask{tk}); code != 0 {
|
||||
t.Fatalf("runExperiment exit = %d, want 0 (every run failed, but the batch finished)", code)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func readResultLines(t *testing.T, root string) ([]contextMetrics, []string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(root, "results.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var runs []contextMetrics
|
||||
var lines []string
|
||||
for text := range strings.SplitSeq(strings.TrimSpace(string(raw)), "\n") {
|
||||
var m contextMetrics
|
||||
if err := json.Unmarshal([]byte(text), &m); err != nil {
|
||||
t.Fatalf("results.jsonl: %v", err)
|
||||
}
|
||||
runs = append(runs, m)
|
||||
lines = append(lines, text)
|
||||
}
|
||||
return runs, lines
|
||||
}
|
||||
|
||||
// A run that failed before the model turn produced no measurement. Recording it
|
||||
// as a zero-valued contextMetrics puts it in the funnel's Scored denominator,
|
||||
// in "Where it broke", and in the dose curve as a real zero.
|
||||
func TestBatchDoesNotRecordAFailedRunAsAResult(t *testing.T) {
|
||||
root := runBlockedBatch(t)
|
||||
runs, lines := readResultLines(t, root)
|
||||
if len(runs) == 0 {
|
||||
t.Fatal("no runs recorded at all")
|
||||
}
|
||||
for i, m := range runs {
|
||||
if m.Task == "" || m.Arm == "" || m.FailureStage == "" {
|
||||
t.Fatalf("results.jsonl line %d records a run that never ran as a result "+
|
||||
"(task=%q arm=%q failure_stage=%q):\n %s", i+1, m.Task, m.Arm, m.FailureStage, lines[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest is the artefact that says which runs counted, so a failed run
|
||||
// must not be added to CompletedRuns or to the task id list.
|
||||
func TestBatchManifestDoesNotReportFailedRunsAsCompleted(t *testing.T) {
|
||||
root := runBlockedBatch(t)
|
||||
var manifest batchManifest
|
||||
data, err := os.ReadFile(filepath.Join(root, "batch.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range manifest.TaskIDs {
|
||||
if id == "" {
|
||||
t.Fatalf("manifest lists an empty task id: task_ids=%q (completed=%d planned=%d)",
|
||||
manifest.TaskIDs, manifest.CompletedRuns, manifest.PlannedRuns)
|
||||
}
|
||||
}
|
||||
if manifest.CompletedRuns == manifest.PlannedRuns {
|
||||
t.Fatalf("manifest reports every planned run completed although none produced evidence: "+
|
||||
"completed=%d planned=%d task_ids=%q", manifest.CompletedRuns, manifest.PlannedRuns, manifest.TaskIDs)
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// batchInfo is what the mode itself knows before any run happens. Everything
|
||||
// else in the manifest is derived from the runs that were written, so a figure
|
||||
// in it cannot drift from the artifact beside it.
|
||||
type batchInfo struct {
|
||||
Mode string
|
||||
Planned int
|
||||
Started time.Time
|
||||
Dry bool
|
||||
// Repo is the checkout the batch ran from, captured before the harness
|
||||
// isolates its workspace: git cannot describe a directory that is gone.
|
||||
Repo string
|
||||
}
|
||||
|
||||
// batchManifest is written beside results.jsonl so a later reader does not have
|
||||
// to reconstruct what the batch was from a terminal scrollback.
|
||||
type batchManifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Mode string `json:"mode"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
GitCommit string `json:"git_commit"`
|
||||
GitDirty *bool `json:"git_dirty,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Window int `json:"window"`
|
||||
Generations int `json:"generations"`
|
||||
TaskIDs []string `json:"task_ids"`
|
||||
Arms []string `json:"arms"`
|
||||
PlannedRuns int `json:"planned_runs"`
|
||||
CompletedRuns int `json:"completed_runs"`
|
||||
// FailedRuns counts the planned runs that ended before a transcript existed.
|
||||
// They are rows in results.jsonl, never part of CompletedRuns, and the field
|
||||
// is absent when there were none so an intact batch is unchanged.
|
||||
FailedRuns int `json:"failed_runs,omitempty"`
|
||||
// Usage totals are absent when nothing reported any: an unmeasured cost must
|
||||
// not read as a measured zero. UsageReportedRuns stays, because zero there
|
||||
// is a fact about the batch.
|
||||
UsageReportedRuns int `json:"usage_reported_runs"`
|
||||
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
|
||||
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
|
||||
UsageEstimated bool `json:"usage_estimated,omitempty"`
|
||||
ResultsSHA256 string `json:"results_sha256"`
|
||||
TrajectoriesSHA256 string `json:"trajectories_sha256"`
|
||||
}
|
||||
|
||||
// gitCommitAndDirty reports the checkout the batch ran from. Unknown is written
|
||||
// rather than omitted, so an absent field is never mistaken for a clean tree.
|
||||
func gitCommitAndDirty(repo string) (commit string, dirty *bool) {
|
||||
commit = "unknown"
|
||||
if repo == "" {
|
||||
return commit, nil
|
||||
}
|
||||
in := func(args ...string) ([]byte, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
return cmd.Output()
|
||||
}
|
||||
if out, err := in("rev-parse", "--short", "HEAD"); err == nil {
|
||||
commit = strings.TrimSpace(string(out))
|
||||
}
|
||||
if out, err := in("status", "--porcelain"); err == nil {
|
||||
state := strings.TrimSpace(string(out)) != ""
|
||||
dirty = &state
|
||||
}
|
||||
return commit, dirty
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", sha256.Sum256(data)), nil
|
||||
}
|
||||
|
||||
func sortedKeys(set map[string]bool) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// writeBatchManifest records the batch beside the runs it describes. It runs
|
||||
// last, so its digests are of what is already on disk.
|
||||
func writeBatchManifest(root string, info batchInfo, all []contextMetrics) error {
|
||||
ids, arms := map[string]bool{}, map[string]bool{}
|
||||
var prompt, completion, cacheHit, cacheMiss, requests, reported, failed int
|
||||
estimated := false
|
||||
for _, m := range all {
|
||||
// A failed run has no task, arm or evidence to contribute: it is counted
|
||||
// as a failure below and is not part of what the batch measured.
|
||||
if m.Failed {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
ids[m.Task] = true
|
||||
arms[m.Arm] = true
|
||||
if m.UsageReportedRequests > 0 {
|
||||
reported++
|
||||
requests += m.UsageReportedRequests
|
||||
prompt += m.PromptTokens
|
||||
completion += m.CompletionTokens
|
||||
cacheHit += m.CacheHitTokens
|
||||
cacheMiss += m.CacheMissTokens
|
||||
estimated = estimated || m.UsageEstimated
|
||||
}
|
||||
}
|
||||
commit, dirty := gitCommitAndDirty(info.Repo)
|
||||
model := runModel
|
||||
if info.Dry {
|
||||
model = "scripted"
|
||||
}
|
||||
manifest := batchManifest{
|
||||
SchemaVersion: 1, Mode: info.Mode,
|
||||
StartedAt: info.Started.UTC().Format(time.RFC3339),
|
||||
FinishedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
GitCommit: commit, GitDirty: dirty,
|
||||
Model: model, Window: fixtureWindow, Generations: fixtureGenerations,
|
||||
TaskIDs: sortedKeys(ids), Arms: sortedKeys(arms),
|
||||
PlannedRuns: info.Planned, CompletedRuns: len(all) - failed, FailedRuns: failed,
|
||||
UsageReportedRuns: reported, UsageReportedRequests: requests,
|
||||
PromptTokens: prompt, CompletionTokens: completion,
|
||||
CacheHitTokens: cacheHit, CacheMissTokens: cacheMiss, UsageEstimated: estimated,
|
||||
}
|
||||
var err error
|
||||
if manifest.ResultsSHA256, err = fileSHA256(filepath.Join(root, "results.jsonl")); err != nil {
|
||||
return err
|
||||
}
|
||||
if manifest.TrajectoriesSHA256, err = fileSHA256(filepath.Join(root, "trajectories.jsonl")); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(root, "batch.json"), append(data, '\n'), 0o644)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func seedArtifacts(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
for _, name := range []string{"results.jsonl", "trajectories.jsonl"} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T, root string) (batchManifest, string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(root, "batch.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got batchManifest
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got, string(data)
|
||||
}
|
||||
|
||||
func TestBatchManifestRecordsWhatTheBatchWas(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seedArtifacts(t, root)
|
||||
all := []contextMetrics{
|
||||
{Task: "b", Arm: "index-off"},
|
||||
{Task: "a", Arm: "index-off"},
|
||||
{Task: "a", Arm: "index-half", UsageReportedRequests: 2, PromptTokens: 100,
|
||||
CompletionTokens: 10, CacheHitTokens: 60, CacheMissTokens: 40},
|
||||
}
|
||||
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 4, Started: time.Now().Add(-time.Minute)}, all); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := readManifest(t, root)
|
||||
if got.SchemaVersion != 1 || got.Mode != "run-index" || got.PlannedRuns != 4 || got.CompletedRuns != 3 {
|
||||
t.Fatalf("manifest header = %+v", got)
|
||||
}
|
||||
if strings.Join(got.TaskIDs, ",") != "a,b" || strings.Join(got.Arms, ",") != "index-half,index-off" {
|
||||
t.Fatalf("ids/arms = %v / %v, want sorted and unique", got.TaskIDs, got.Arms)
|
||||
}
|
||||
if got.UsageReportedRuns != 1 || got.UsageReportedRequests != 2 || got.PromptTokens != 100 ||
|
||||
got.CompletionTokens != 10 || got.CacheHitTokens != 60 || got.CacheMissTokens != 40 {
|
||||
t.Fatalf("usage totals = %+v", got)
|
||||
}
|
||||
// No repo means git could not describe the checkout: unknown commit, and no
|
||||
// dirty flag at all, because "clean" is a claim this batch cannot make.
|
||||
if got.GitCommit != "unknown" || got.GitDirty != nil {
|
||||
t.Fatalf("git state = %q / %v, want unknown and no dirty flag", got.GitCommit, got.GitDirty)
|
||||
}
|
||||
want := fmt.Sprintf("%x", sha256.Sum256([]byte("{}\n")))
|
||||
if got.ResultsSHA256 != want || got.TrajectoriesSHA256 != want {
|
||||
t.Fatalf("digests = %q / %q, want %q", got.ResultsSHA256, got.TrajectoriesSHA256, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A dry batch pays nothing, so its cost fields must be absent rather than zero:
|
||||
// an unmeasured spend must not read as a measured one.
|
||||
func TestBatchManifestLeavesUnreportedUsageAbsent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seedArtifacts(t, root)
|
||||
all := []contextMetrics{{Task: "a", Arm: "index-off"}, {Task: "a", Arm: "index-half"}}
|
||||
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 2, Started: time.Now(), Dry: true}, all); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, raw := readManifest(t, root)
|
||||
if got.UsageReportedRuns != 0 {
|
||||
t.Fatalf("usage_reported_runs = %d, want 0 reported", got.UsageReportedRuns)
|
||||
}
|
||||
if got.Model != "scripted" {
|
||||
t.Fatalf("dry batch model = %q, want scripted", got.Model)
|
||||
}
|
||||
for _, absent := range []string{"usage_reported_requests", "prompt_tokens", "completion_tokens", "cache_hit_tokens"} {
|
||||
if strings.Contains(raw, absent) {
|
||||
t.Fatalf("a dry batch wrote %q:\n%s", absent, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// boundaryRuns builds the input reportBoundaries reads: one scored run for an
|
||||
// index task's cue side, and one failed run for another index task's cue side.
|
||||
// The runs are constructed because the report is a pure function of the slice;
|
||||
// the batch path that produces them is covered by the failed-batch tests.
|
||||
func boundaryRuns(t *testing.T) (all []contextMetrics, scoredTask, failedTask string) {
|
||||
t.Helper()
|
||||
index := indexTasks()
|
||||
if len(index) < 2 {
|
||||
t.Fatalf("need at least two index tasks, have %d", len(index))
|
||||
}
|
||||
scored, failed := index[0], index[1]
|
||||
cue, noCue := boundaryPair(scored.CueTier)
|
||||
if cue == "" || noCue == "" {
|
||||
t.Fatalf("task %s has an empty boundary pair (%q, %q)", scored.ID, cue, noCue)
|
||||
}
|
||||
failedCue, _ := boundaryPair(failed.CueTier)
|
||||
if failedCue == "" {
|
||||
t.Fatalf("task %s has no cue side", failed.ID)
|
||||
}
|
||||
all = []contextMetrics{
|
||||
{Task: scored.ID, Arm: "index-" + cue, AnswerRecovered: true, SearchCalls: 1, RecallReturnedTokens: 318},
|
||||
{Task: scored.ID, Arm: "index-" + noCue, AnswerRecovered: false},
|
||||
{Task: failed.ID, Arm: "index-" + failedCue, Failed: true, FailureStage: stageNoEvidence},
|
||||
}
|
||||
return all, scored.ID, failed.ID
|
||||
}
|
||||
|
||||
// TestBoundaryReportExcludesFailedRuns: a run that never produced a transcript
|
||||
// must not appear in the cue-boundary table, which was the last reporting exit
|
||||
// that still folded it in as a zero measurement.
|
||||
func TestBoundaryReportExcludesFailedRuns(t *testing.T) {
|
||||
all, scoredTask, failedTask := boundaryRuns(t)
|
||||
out := captureStdout(t, func() { reportBoundaries(all) })
|
||||
if strings.Contains(out, failedTask) {
|
||||
t.Fatalf("the boundary table printed the failed task %q as a measurement:\n%s", failedTask, out)
|
||||
}
|
||||
if !strings.Contains(out, scoredTask) {
|
||||
t.Fatalf("the boundary table dropped the scored task %q, so the exclusion is not specific to failures:\n%s", scoredTask, out)
|
||||
}
|
||||
if !strings.Contains(out, "cue-present") || !strings.Contains(out, "cue-absent") {
|
||||
t.Fatalf("the pooled comparison disappeared:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoundaryReportSaysNothingToCompareWhenEveryRunFailed: with no scored run
|
||||
// the report must say so, rather than printing a comparison of zeros.
|
||||
func TestBoundaryReportSaysNothingToCompareWhenEveryRunFailed(t *testing.T) {
|
||||
all, _, _ := boundaryRuns(t)
|
||||
failedOnly := make([]contextMetrics, 0, len(all))
|
||||
for _, m := range all {
|
||||
m.Failed = true
|
||||
m.FailureStage = stageNoEvidence
|
||||
failedOnly = append(failedOnly, m)
|
||||
}
|
||||
out := captureStdout(t, func() { reportBoundaries(failedOnly) })
|
||||
if !strings.Contains(out, "scored=0") || !strings.Contains(out, "failed=") {
|
||||
t.Fatalf("an all-failed batch did not report scored=0 with a failed count:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "cue-present") || strings.Contains(out, "cue-absent") {
|
||||
t.Fatalf("an all-failed batch still printed a pooled comparison of zeros:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
// capture.go — what the agent actually hands a provider.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
"reasonix/internal/contract/ablation"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/runtime/agent"
|
||||
_ "reasonix/internal/tools/builtin"
|
||||
)
|
||||
|
||||
// A projection is what the agent believes; a provider.Request is what the model
|
||||
// is told. Rendering, sanitization and tool-schema construction sit between
|
||||
// them, so the arm's contract is checked on the request.
|
||||
|
||||
type captureProvider struct {
|
||||
requests []provider.Request
|
||||
}
|
||||
|
||||
func (p *captureProvider) Name() string { return "contextbench-capture" }
|
||||
|
||||
// Stream records the request and answers with a clean terminal response. An
|
||||
// empty answer would buy an empty-final retry and a second request, which is
|
||||
// not the one round this is here to inspect.
|
||||
func (p *captureProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.requests = append(p.requests, cloneRequest(req))
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "contextbench preflight complete"}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func cloneRequest(req provider.Request) provider.Request {
|
||||
out := req
|
||||
out.Messages = append([]provider.Message(nil), req.Messages...)
|
||||
out.Tools = append([]provider.ToolSchema(nil), req.Tools...)
|
||||
return out
|
||||
}
|
||||
|
||||
// captureRequest rebuilds the session the way a resume does — transcript from
|
||||
// disk, projection from its sidecar, the shipped tool surface at this arm —
|
||||
// and returns the request the first round hands the provider.
|
||||
func captureRequest(f builtFixture, arm ablation.Set, prompt string) (provider.Request, error) {
|
||||
sess, err := sessionstore.LoadSession(f.Path)
|
||||
if err != nil {
|
||||
return provider.Request{}, fmt.Errorf("load session: %w", err)
|
||||
}
|
||||
reg := tool.NewRegistry()
|
||||
for _, t := range tool.Builtins() {
|
||||
reg.Add(t)
|
||||
}
|
||||
boot.ApplyUnifiedProviderToolSurface(reg, false, arm)
|
||||
|
||||
cap := &captureProvider{}
|
||||
a := agent.New(cap, reg, sess, agent.Options{
|
||||
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
|
||||
SessionPath: f.Path, KeepPolicy: agent.KeepErrors, Ablation: arm,
|
||||
}, event.Discard)
|
||||
a.LoadProjectionSidecar(f.Path)
|
||||
|
||||
if err := a.Run(context.Background(), prompt); err != nil {
|
||||
return provider.Request{}, fmt.Errorf("run: %w", err)
|
||||
}
|
||||
// The turn's own request is the one carrying tools. A fold on the way in
|
||||
// asks the same provider for a digest, and that request has none — taking
|
||||
// the first request blindly would inspect the summarizer's prompt.
|
||||
for _, req := range cap.requests {
|
||||
if len(req.Tools) > 0 {
|
||||
return req, nil
|
||||
}
|
||||
}
|
||||
return provider.Request{}, fmt.Errorf("no request carried tools (%d captured)", len(cap.requests))
|
||||
}
|
||||
|
||||
// surfaceField is one place a model could read a string, named so a leak
|
||||
// report says where rather than only that.
|
||||
type surfaceField struct {
|
||||
Path string
|
||||
Text string
|
||||
}
|
||||
|
||||
// providerSurface enumerates every provider-visible string in a request.
|
||||
// Enumerated rather than marshalled whole: a Message also carries local-only
|
||||
// fields, and scanning those would fail on bytes no model ever sees.
|
||||
func providerSurface(req provider.Request) []surfaceField {
|
||||
var out []surfaceField
|
||||
add := func(path, text string) {
|
||||
if strings.TrimSpace(text) != "" {
|
||||
out = append(out, surfaceField{Path: path, Text: text})
|
||||
}
|
||||
}
|
||||
for i, m := range req.Messages {
|
||||
add(fmt.Sprintf("messages[%d].content", i), m.Content)
|
||||
add(fmt.Sprintf("messages[%d].reasoning_content", i), m.ReasoningContent)
|
||||
add(fmt.Sprintf("messages[%d].name", i), m.Name)
|
||||
for j, tc := range m.ToolCalls {
|
||||
add(fmt.Sprintf("messages[%d].tool_calls[%d].name", i, j), tc.Name)
|
||||
add(fmt.Sprintf("messages[%d].tool_calls[%d].arguments", i, j), tc.Arguments)
|
||||
}
|
||||
for j, item := range m.ResponsesItems {
|
||||
add(fmt.Sprintf("messages[%d].responses_items[%d]", i, j), string(item))
|
||||
}
|
||||
}
|
||||
for i, t := range req.Tools {
|
||||
add(fmt.Sprintf("tools[%d].name", i), t.Name)
|
||||
add(fmt.Sprintf("tools[%d].description", i), t.Description)
|
||||
if len(t.Parameters) > 0 {
|
||||
b, err := json.Marshal(t.Parameters)
|
||||
if err == nil {
|
||||
add(fmt.Sprintf("tools[%d].parameters", i), string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findInSurface reports every place a marker is readable.
|
||||
func findInSurface(req provider.Request, marker string) []string {
|
||||
var hits []string
|
||||
for _, f := range providerSurface(req) {
|
||||
if strings.Contains(f.Text, marker) {
|
||||
hits = append(hits, f.Path)
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// recallToolSchema returns the recall tool as the provider sees it.
|
||||
func recallToolSchema(req provider.Request) (provider.ToolSchema, bool) {
|
||||
for _, t := range req.Tools {
|
||||
if t.Name == "recall" {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return provider.ToolSchema{}, false
|
||||
}
|
||||
|
||||
// localOnlyLeaks reports host-side fields that should never survive the
|
||||
// provider boundary. A hit here is a boundary regression, marker or no marker.
|
||||
func localOnlyLeaks(req provider.Request) []string {
|
||||
var out []string
|
||||
for i, m := range req.Messages {
|
||||
switch {
|
||||
case m.RawContent != "":
|
||||
out = append(out, fmt.Sprintf("messages[%d].raw_content", i))
|
||||
case m.LocalOnly:
|
||||
out = append(out, fmt.Sprintf("messages[%d].local_only", i))
|
||||
case m.DecisionReceipt != nil || len(m.DecisionReceipts) > 0:
|
||||
out = append(out, fmt.Sprintf("messages[%d].decision_receipt", i))
|
||||
case m.ToolExecution != nil:
|
||||
out = append(out, fmt.Sprintf("messages[%d].tool_execution", i))
|
||||
case len(m.MemoryCitations) > 0:
|
||||
out = append(out, fmt.Sprintf("messages[%d].memory_citations", i))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// searchWording is what a search-off arm must never be told. The arm exists to
|
||||
// reproduce the runtime before search; an arm offered a capability it does not
|
||||
// have is a different experiment.
|
||||
var searchWording = []string{"recall with a query", "search the whole folded region", "query still finds"}
|
||||
@@ -1,671 +0,0 @@
|
||||
// contextbench.go — what the model did with the affordance, read off structure.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/tokencount"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// Every judgement here reads a tool call's name and its JSON arguments, or a
|
||||
// canonical position, and never the model's prose. "I will search the history"
|
||||
// is not a search; a recall call with a query is.
|
||||
|
||||
// failure stages, in funnel order. Exactly one is reported per run, except for
|
||||
// NoEvidence: that stage belongs to a run with no measurement at all.
|
||||
const (
|
||||
stageReadMissed = "NoSearch" // never asked, and never read the target either
|
||||
stageSearchMiss = "SearchMiss" // searched, target never came back
|
||||
stageHitNotRead = "HitNotRead" // target was returned and never opened
|
||||
stageAnswerWrong = "ReadButAnswerWrong" // opened it and still answered wrong
|
||||
stageRecovered = "Recovered"
|
||||
stageContaminated = "Contaminated"
|
||||
stageNoEvidence = "NoEvidence"
|
||||
|
||||
// Routing is judged by model round, not by the linear order of tool events:
|
||||
// two calls in one round are one parallel fan-out, and calling that
|
||||
// "workspace first" would report a choice the model never made.
|
||||
routeMemoryFirst = "MemoryFirst"
|
||||
routeWorkspaceFirst = "WorkspaceFirst"
|
||||
routeParallel = "Parallel"
|
||||
routeNeither = "Neither"
|
||||
stageCueRead = "DirectRead" // read the target from an index address, no search needed
|
||||
stageNoRetrieval = "NoRetrieval" // answered without touching recall at all
|
||||
toolRecall = "recall"
|
||||
)
|
||||
|
||||
// searchAttempt is one query the model wrote, and what it got back. Kept per
|
||||
// attempt rather than aggregated: the question is why a retrieval that the host
|
||||
// probe ranks first takes the model more than one try.
|
||||
type searchAttempt struct {
|
||||
Ordinal int `json:"ordinal"`
|
||||
Round int `json:"round"`
|
||||
Query string `json:"query"`
|
||||
TargetRank int `json:"target_rank"` // 0 = the target did not come back
|
||||
ResultCount int `json:"result_count"`
|
||||
// Reformulation classifies this query against the one before it, and
|
||||
// MissingProbeTerms are the host probe's terms this query left out. Both
|
||||
// are derived from token sets: no judge, and nothing read out of prose.
|
||||
Reformulation string `json:"reformulation,omitempty"`
|
||||
MissingProbeTerms []string `json:"missing_probe_terms,omitempty"`
|
||||
}
|
||||
|
||||
// contextMetrics is one run's behaviour.
|
||||
type contextMetrics struct {
|
||||
Task string `json:"task"`
|
||||
Arm string `json:"arm"`
|
||||
|
||||
SearchCalls int `json:"search_calls"`
|
||||
// SearchHits counts searches that returned anything; TargetSearchHits
|
||||
// counts searches that returned the target. The gap is query quality.
|
||||
SearchHits int `json:"search_hits"`
|
||||
TargetSearchHits int `json:"target_search_hits"`
|
||||
FirstTargetRank int `json:"first_target_rank"` // 0 = never returned
|
||||
// FirstHitCoverage: how much of the answer the first hit handed over.
|
||||
// rank=1 with coverage 1/3 is a window too short to answer with, not a
|
||||
// model that would not stop.
|
||||
FirstHitCoverage int `json:"first_hit_coverage"`
|
||||
AnswerValues int `json:"answer_values"`
|
||||
|
||||
ReadCalls int `json:"read_calls"`
|
||||
TargetRead bool `json:"target_read"`
|
||||
ReadAfterHit bool `json:"read_after_hit"`
|
||||
// RecallReadWithoutSearch: the index addressed the target, so no search was
|
||||
// needed. Not "direct read" — read_file is a read too, and in an audit the
|
||||
// two mean opposite things.
|
||||
RecallReadWithoutSearch bool `json:"recall_read_without_search"`
|
||||
SearchThenRecallRead bool `json:"search_then_recall_read"`
|
||||
|
||||
FirstSearchRound int `json:"first_search_round"`
|
||||
FirstReadRound int `json:"first_read_round"`
|
||||
RetrievalRounds int `json:"retrieval_rounds"`
|
||||
|
||||
// RecallReturnedTokens is what recall paged back in. The index saves
|
||||
// resident prefix; this is what is paid dynamically instead.
|
||||
RecallReturnedTokens int `json:"recall_returned_tokens"`
|
||||
|
||||
// Provider usage as reported on the stream, or empty for a dry run. The count
|
||||
// is completions that reported usage, so it is not a count of attempts.
|
||||
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
|
||||
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
|
||||
UsageEstimated bool `json:"usage_estimated,omitempty"`
|
||||
|
||||
Searches []searchAttempt `json:"searches,omitempty"`
|
||||
// PostHitSearches are queries issued after the target had already been
|
||||
// returned: not a query problem, a result-to-read problem.
|
||||
PostHitSearches int `json:"post_hit_searches"`
|
||||
|
||||
AnswerRecovered bool `json:"answer_recovered"`
|
||||
// AnswerAmbiguous marks an answer that offered more than one candidate for
|
||||
// a value. Markers matching proves the right fact appears; it does not
|
||||
// prove it was submitted as the answer. Recorded, never acted on.
|
||||
AnswerAmbiguous bool `json:"answer_ambiguous,omitempty"`
|
||||
AmbiguousVar string `json:"ambiguous_var,omitempty"`
|
||||
MissingMarkers []string `json:"missing_markers,omitempty"`
|
||||
// FinalAnswer is kept bounded so a surprising run can be read back. A
|
||||
// metric nobody can audit is a claim, not a measurement.
|
||||
FinalAnswer string `json:"final_answer,omitempty"`
|
||||
FailureStage string `json:"failure_stage"`
|
||||
TimeoutReason string `json:"timeout_reason,omitempty"`
|
||||
|
||||
// Failed marks a run that ended before a transcript existed: it has no score
|
||||
// and no place in the statistics. The row stays in results.jsonl with its
|
||||
// task, arm and stage, so a failure is counted rather than dropped.
|
||||
Failed bool `json:"failed,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
|
||||
// UnexpectedWorkTools are investigation tools used instead of recall.
|
||||
// Reaching for the workspace is behaviour, not noise: how a model routes
|
||||
// its uncertainty when memory is missing is part of what is measured.
|
||||
UnexpectedWorkTools []string `json:"unexpected_work_tools,omitempty"`
|
||||
EscapeCalls int `json:"escape_calls"`
|
||||
EscapeBeforeFirstRecall int `json:"escape_before_first_recall"`
|
||||
EscapeAfterFirstRecall int `json:"escape_after_first_recall"`
|
||||
// Contaminated: the answer was reachable through something other than
|
||||
// recall. Asserting this beats trusting the isolation, and a run that trips
|
||||
// it leaves the statistics rather than inflating one.
|
||||
Contaminated bool `json:"contaminated,omitempty"`
|
||||
LeakedVia string `json:"leaked_via,omitempty"`
|
||||
LeakedMarker string `json:"leaked_marker,omitempty"`
|
||||
LeakedArgs string `json:"leaked_args,omitempty"`
|
||||
LeakedOutput string `json:"leaked_output,omitempty"`
|
||||
// EscapeFoundAnswer separates "looked at the workspace" from "found the
|
||||
// answer there": different facts about the same call.
|
||||
EscapeFoundAnswer bool `json:"escape_found_answer,omitempty"`
|
||||
|
||||
CueVisible bool `json:"cue_visible"`
|
||||
|
||||
// trajectory is kept out of the metric line and written beside it, so a
|
||||
// future question can be asked of this batch instead of a new one.
|
||||
trajectory []provider.Message
|
||||
|
||||
// CueDirectRead: the cue was addressed, the first memory action was a
|
||||
// positions read, and it covered the target. Crediting any positions call
|
||||
// would hand the index a search's work.
|
||||
CueDirectRead bool `json:"cue_direct_read"`
|
||||
|
||||
// Sufficiency, not hit. A search that returns the target at rank 1 has not
|
||||
// necessarily returned the answer: the snippet is bounded. The stopping
|
||||
// boundary is the round where everything needed was readable.
|
||||
FirstSufficientRound int `json:"first_sufficient_round"`
|
||||
SufficientVia string `json:"sufficient_via"` // snippet | read | never
|
||||
RoundsAfterSufficient int `json:"rounds_after_sufficient"`
|
||||
SearchesAfterSufficient int `json:"searches_after_sufficient"`
|
||||
ReadsAfterSufficient int `json:"reads_after_sufficient"`
|
||||
EscapesAfterSufficient int `json:"escapes_after_sufficient"`
|
||||
RecallTokensAfterSufficient int `json:"recall_tokens_after_sufficient"`
|
||||
StoppingClass string `json:"stopping_class"`
|
||||
|
||||
// What happened after the answer was already in hand. A timeout that spent
|
||||
// seven rounds past its target is a stopping problem, and extending the
|
||||
// budget would only buy the loop more time.
|
||||
TargetFirstSeenRound int `json:"target_first_seen_round"`
|
||||
RoundsAfterTarget int `json:"rounds_after_target"`
|
||||
SearchesAfterTarget int `json:"searches_after_target"`
|
||||
RecallTokensAfterTarget int `json:"recall_tokens_after_target"`
|
||||
TargetHeldAtEnd bool `json:"target_held_at_end"`
|
||||
|
||||
// Routing is where the model looked first when its memory was missing.
|
||||
Routing string `json:"routing"`
|
||||
FirstRecallRound int `json:"first_recall_round"`
|
||||
FirstEscapeRound int `json:"first_escape_round"`
|
||||
}
|
||||
|
||||
// hitPosition matches the address recall prints for a search hit. Reading our
|
||||
// own rendered format, not guessing at the model's words.
|
||||
var hitPosition = regexp.MustCompile(`(?m)^#(\d+) `)
|
||||
|
||||
// scoreRun reads the messages one turn appended and reports what happened.
|
||||
// runScorer walks one turn's messages, holding the state a judgement needs:
|
||||
// which calls are open, whether the target has come back, and which search
|
||||
// each result belongs to.
|
||||
type runScorer struct {
|
||||
m contextMetrics
|
||||
inst fixtureInstance
|
||||
target int
|
||||
calls map[string]provider.ToolCall
|
||||
pending map[string]int
|
||||
round int
|
||||
sawTargetHit bool
|
||||
// seen accumulates everything recall has shown the model, so sufficiency is
|
||||
// judged on what it could read rather than on which call produced it.
|
||||
seen strings.Builder
|
||||
sufficient bool
|
||||
}
|
||||
|
||||
// scoreRun reads the messages one turn appended and reports what happened.
|
||||
func scoreRun(msgs []provider.Message, inst fixtureInstance, target int, arm string, cueVisible bool) contextMetrics {
|
||||
s := &runScorer{
|
||||
m: contextMetrics{Task: inst.Task.ID, Arm: arm, CueVisible: cueVisible},
|
||||
inst: inst, target: target,
|
||||
calls: map[string]provider.ToolCall{}, pending: map[string]int{},
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
s.round++
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
s.calls[tc.ID] = tc
|
||||
s.observeCall(tc)
|
||||
}
|
||||
if msg.Role == provider.RoleTool {
|
||||
s.observeResult(msg)
|
||||
}
|
||||
}
|
||||
return s.finish(cueVisible)
|
||||
}
|
||||
|
||||
// observeCall records one tool call: a recall search, a recall read, or a
|
||||
// reach for the workspace.
|
||||
func (s *runScorer) observeCall(tc provider.ToolCall) {
|
||||
m := &s.m
|
||||
if tc.Name != toolRecall {
|
||||
if !containsString(m.UnexpectedWorkTools, tc.Name) {
|
||||
m.UnexpectedWorkTools = append(m.UnexpectedWorkTools, tc.Name)
|
||||
}
|
||||
m.EscapeCalls++
|
||||
if s.sufficient {
|
||||
m.EscapesAfterSufficient++
|
||||
}
|
||||
if m.FirstEscapeRound == 0 {
|
||||
m.FirstEscapeRound = s.round
|
||||
}
|
||||
if m.SearchCalls+m.ReadCalls == 0 {
|
||||
m.EscapeBeforeFirstRecall++
|
||||
} else {
|
||||
m.EscapeAfterFirstRecall++
|
||||
}
|
||||
return
|
||||
}
|
||||
if m.FirstRecallRound == 0 {
|
||||
m.FirstRecallRound = s.round
|
||||
}
|
||||
query, positions := recallArgs(tc.Arguments)
|
||||
switch {
|
||||
case query != "":
|
||||
m.SearchCalls++
|
||||
if m.FirstSearchRound == 0 {
|
||||
m.FirstSearchRound = s.round
|
||||
}
|
||||
if s.sawTargetHit {
|
||||
m.PostHitSearches++
|
||||
m.SearchesAfterTarget++
|
||||
}
|
||||
if s.sufficient {
|
||||
m.SearchesAfterSufficient++
|
||||
}
|
||||
m.Searches = append(m.Searches, searchAttempt{Ordinal: m.SearchCalls, Round: s.round, Query: query})
|
||||
s.pending[tc.ID] = len(m.Searches) - 1
|
||||
case len(positions) > 0:
|
||||
m.ReadCalls++
|
||||
if s.sufficient {
|
||||
m.ReadsAfterSufficient++
|
||||
}
|
||||
if m.FirstReadRound == 0 {
|
||||
m.FirstReadRound = s.round
|
||||
}
|
||||
// A read covers the target when it names its position: recall returns
|
||||
// the tool results under the caller's own address.
|
||||
if containsInt(positions, s.target) {
|
||||
m.TargetRead = true
|
||||
switch {
|
||||
case s.sawTargetHit:
|
||||
m.ReadAfterHit, m.SearchThenRecallRead = true, true
|
||||
case m.SearchCalls == 0:
|
||||
m.RecallReadWithoutSearch = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// observeResult reads what a call returned: contamination, page-in cost, and
|
||||
// whether this search brought the target back.
|
||||
func (s *runScorer) observeResult(msg provider.Message) {
|
||||
m := &s.m
|
||||
call, ok := s.calls[msg.ToolCallID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if call.Name != toolRecall {
|
||||
// Codenames only: a three-digit epoch appears in any long file by
|
||||
// chance, and one did. A model holding the codename holds the numbers
|
||||
// beside it anyway.
|
||||
for _, marker := range s.inst.AnswerMarkers {
|
||||
if len(marker) >= 6 && strings.Contains(msg.Content, marker) {
|
||||
m.Contaminated, m.LeakedVia, m.LeakedMarker = true, call.Name, marker
|
||||
m.EscapeFoundAnswer = true
|
||||
m.LeakedArgs, m.LeakedOutput = truncate(call.Arguments, 200), truncate(msg.Content, 300)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
cost := tokencount.Text(msg.Content)
|
||||
m.RecallReturnedTokens += cost
|
||||
if s.sawTargetHit {
|
||||
m.RecallTokensAfterTarget += cost
|
||||
}
|
||||
if s.sufficient {
|
||||
m.RecallTokensAfterSufficient += cost
|
||||
}
|
||||
// Everything recall showed the model, judged as a whole: the answer may
|
||||
// arrive across a snippet and a read, and either way it is readable.
|
||||
s.seen.WriteString(msg.Content)
|
||||
s.seen.WriteString("\n")
|
||||
if !s.sufficient && allMarkersIn(s.seen.String(), s.inst.AnswerMarkers) {
|
||||
s.sufficient = true
|
||||
m.FirstSufficientRound = s.round
|
||||
query, _ := recallArgs(call.Arguments)
|
||||
m.SufficientVia = "read"
|
||||
if query != "" {
|
||||
m.SufficientVia = "snippet"
|
||||
}
|
||||
}
|
||||
if query, _ := recallArgs(call.Arguments); query == "" {
|
||||
return
|
||||
}
|
||||
positions := searchHitPositions(msg.Content)
|
||||
if len(positions) > 0 {
|
||||
m.SearchHits++
|
||||
}
|
||||
rank := indexOfInt(positions, s.target)
|
||||
if i, ok := s.pending[msg.ToolCallID]; ok {
|
||||
m.Searches[i].TargetRank, m.Searches[i].ResultCount = rank, len(positions)
|
||||
}
|
||||
if rank == 0 {
|
||||
return
|
||||
}
|
||||
m.TargetSearchHits++
|
||||
if !s.sawTargetHit {
|
||||
m.TargetFirstSeenRound = s.round
|
||||
m.FirstHitCoverage = answerCoverage(msg.Content, s.inst.AnswerMarkers)
|
||||
}
|
||||
s.sawTargetHit = true
|
||||
if m.FirstTargetRank == 0 {
|
||||
m.FirstTargetRank = rank
|
||||
}
|
||||
}
|
||||
|
||||
// finish derives what only the whole walk can say.
|
||||
func (s *runScorer) finish(cueVisible bool) contextMetrics {
|
||||
m := s.m
|
||||
m.RetrievalRounds = m.SearchCalls + m.ReadCalls
|
||||
m.TargetHeldAtEnd = s.sawTargetHit || m.TargetRead
|
||||
if m.TargetFirstSeenRound > 0 {
|
||||
m.RoundsAfterTarget = s.round - m.TargetFirstSeenRound
|
||||
}
|
||||
m.Routing = routing(m.FirstRecallRound, m.FirstEscapeRound)
|
||||
m.CueDirectRead = cueVisible && m.RecallReadWithoutSearch
|
||||
if m.SufficientVia == "" {
|
||||
m.SufficientVia = "never"
|
||||
}
|
||||
if m.FirstSufficientRound > 0 {
|
||||
m.RoundsAfterSufficient = s.round - m.FirstSufficientRound
|
||||
}
|
||||
m.AnswerValues = len(s.inst.AnswerMarkers)
|
||||
m.StoppingClass = stoppingClass(m)
|
||||
classifySearches(&m, s.inst)
|
||||
return m
|
||||
}
|
||||
|
||||
// scoreAnswer grades the final text on the task's markers. Nonces, matched
|
||||
// exactly: no judge model, and nothing a general answer could satisfy.
|
||||
func (m *contextMetrics) scoreAnswer(final string, inst fixtureInstance) {
|
||||
lower := strings.ToLower(final)
|
||||
for _, marker := range inst.AnswerMarkers {
|
||||
if !strings.Contains(lower, strings.ToLower(marker)) {
|
||||
m.MissingMarkers = append(m.MissingMarkers, marker)
|
||||
}
|
||||
}
|
||||
m.AnswerRecovered = len(m.MissingMarkers) == 0
|
||||
m.AnswerAmbiguous, m.AmbiguousVar = ambiguousAnswer(final, inst)
|
||||
m.FailureStage = m.stage()
|
||||
if len(final) > 1200 {
|
||||
final = final[:1200] + "…"
|
||||
}
|
||||
m.FinalAnswer = final
|
||||
}
|
||||
|
||||
// stage places the run in the funnel. One label, chosen at the first step that
|
||||
// did not happen, so a table of stages reads as where the retrieval broke.
|
||||
func (m contextMetrics) stage() string {
|
||||
switch {
|
||||
case m.Contaminated:
|
||||
return stageContaminated
|
||||
case m.AnswerRecovered && m.RecallReadWithoutSearch:
|
||||
return stageCueRead
|
||||
case m.AnswerRecovered:
|
||||
return stageRecovered
|
||||
case m.TargetRead:
|
||||
return stageAnswerWrong
|
||||
case m.TargetSearchHits > 0:
|
||||
return stageHitNotRead
|
||||
case m.SearchCalls > 0:
|
||||
return stageSearchMiss
|
||||
case m.ReadCalls > 0:
|
||||
return stageReadMissed
|
||||
default:
|
||||
return stageNoRetrieval
|
||||
}
|
||||
}
|
||||
|
||||
// recallArgs pulls the two fields that say which operation a recall call is.
|
||||
func recallArgs(args string) (query string, positions []int) {
|
||||
var parsed struct {
|
||||
Query string `json:"query"`
|
||||
Positions []int `json:"positions"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(args), &parsed); err != nil {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(parsed.Query), parsed.Positions
|
||||
}
|
||||
|
||||
// searchHitPositions reads the addresses out of a search result, in rank order.
|
||||
func searchHitPositions(text string) []int {
|
||||
var out []int
|
||||
for _, match := range hitPosition.FindAllStringSubmatch(text, -1) {
|
||||
if n, err := strconv.Atoi(match[1]); err == nil {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsInt(list []int, want int) bool { return slices.Contains(list, want) }
|
||||
|
||||
// indexOfInt returns a 1-based rank, or 0 when absent.
|
||||
func indexOfInt(list []int, want int) int {
|
||||
for i, v := range list {
|
||||
if v == want {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func containsString(list []string, want string) bool { return slices.Contains(list, want) }
|
||||
|
||||
// funnel summarizes one arm's runs the way the experiment is read.
|
||||
type funnel struct {
|
||||
Arm string
|
||||
Runs int
|
||||
Scored, Contaminated, Errored int
|
||||
Searched, TargetHit, TargetRead, Answered int
|
||||
CueReads int
|
||||
SearchCalls, ReadCalls, RecallTokens int
|
||||
Escapes, EscapeCalls int
|
||||
Stages, Routes map[string]int
|
||||
}
|
||||
|
||||
func summarize(arm string, runs []contextMetrics) funnel {
|
||||
f := funnel{Arm: arm, Runs: len(runs), Stages: map[string]int{}, Routes: map[string]int{}}
|
||||
for _, r := range runs {
|
||||
// A run that never produced a transcript is not a scored run and has no
|
||||
// stage to place: counting it would put an empty name in the funnel, the
|
||||
// stage table and the routes.
|
||||
if r.Failed {
|
||||
continue
|
||||
}
|
||||
if r.Contaminated {
|
||||
f.Contaminated++
|
||||
f.Stages[r.FailureStage]++
|
||||
continue
|
||||
}
|
||||
if r.FailureStage == "RunError" {
|
||||
f.Errored++
|
||||
f.Stages[r.FailureStage]++
|
||||
continue
|
||||
}
|
||||
f.Scored++
|
||||
if r.SearchCalls > 0 {
|
||||
f.Searched++
|
||||
}
|
||||
if r.TargetSearchHits > 0 {
|
||||
f.TargetHit++
|
||||
}
|
||||
if r.TargetRead {
|
||||
f.TargetRead++
|
||||
}
|
||||
if r.AnswerRecovered {
|
||||
f.Answered++
|
||||
}
|
||||
if r.RecallReadWithoutSearch {
|
||||
f.CueReads++
|
||||
}
|
||||
if len(r.UnexpectedWorkTools) > 0 {
|
||||
f.Escapes++
|
||||
}
|
||||
f.EscapeCalls += r.EscapeCalls
|
||||
f.SearchCalls += r.SearchCalls
|
||||
f.ReadCalls += r.ReadCalls
|
||||
f.RecallTokens += r.RecallReturnedTokens
|
||||
f.Stages[r.FailureStage]++
|
||||
if r.FailureStage != "RunError" {
|
||||
f.Routes[r.Routing]++
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f funnel) line() string {
|
||||
per := func(n int) float64 {
|
||||
if f.Scored == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(n) / float64(f.Scored)
|
||||
}
|
||||
line := fmt.Sprintf("%-14s runs=%-3d answered=%d/%d searched=%d target-hit=%d cue-read=%d search/task=%.2f recall-tok/task=%.0f escape/task=%.2f",
|
||||
f.Arm, f.Scored, f.Answered, f.Scored, f.Searched, f.TargetHit, f.CueReads,
|
||||
per(f.SearchCalls), per(f.RecallTokens), per(f.EscapeCalls))
|
||||
if f.Contaminated > 0 {
|
||||
line += fmt.Sprintf(" CONTAMINATED=%d", f.Contaminated)
|
||||
}
|
||||
if f.Errored > 0 {
|
||||
line += fmt.Sprintf(" ERRORED=%d", f.Errored)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func writeJSONLine(w io.Writer, v any) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(w, string(b))
|
||||
return err
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
// routing says where the model looked first, comparing rounds rather than
|
||||
// event order. Same round is a parallel fan-out, not a preference.
|
||||
func routing(recallRound, escapeRound int) string {
|
||||
switch {
|
||||
case recallRound == 0 && escapeRound == 0:
|
||||
return routeNeither
|
||||
case recallRound == 0:
|
||||
return routeWorkspaceFirst
|
||||
case escapeRound == 0:
|
||||
return routeMemoryFirst
|
||||
case recallRound < escapeRound:
|
||||
return routeMemoryFirst
|
||||
case escapeRound < recallRound:
|
||||
return routeWorkspaceFirst
|
||||
default:
|
||||
return routeParallel
|
||||
}
|
||||
}
|
||||
|
||||
// ambiguousAnswer reports an answer naming more than one candidate of a
|
||||
// codename's shape. Only codenames are checked: they carry a stem this run
|
||||
// declared, so two distinct "fence-…" values in one answer is one answer too
|
||||
// many. Numbers have no such shape and are left alone.
|
||||
func ambiguousAnswer(final string, inst fixtureInstance) (bool, string) {
|
||||
for _, spec := range inst.Task.Vars {
|
||||
if spec.Kind != varCodename {
|
||||
continue
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, match := range regexp.MustCompile(regexp.QuoteMeta(spec.Word)+`-[a-z0-9]{3,}`).FindAllString(final, -1) {
|
||||
seen[match] = true
|
||||
}
|
||||
if len(seen) > 1 {
|
||||
return true, spec.Name
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func allMarkersIn(text string, markers []string) bool {
|
||||
if len(markers) == 0 {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
for _, marker := range markers {
|
||||
if !strings.Contains(lower, strings.ToLower(marker)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Stopping classes. The boundary is sufficiency, not the target coming back:
|
||||
// a rank-1 hit whose snippet was too short to answer leaves a read entirely
|
||||
// justified, and counting that as waste would blame the model for the bound.
|
||||
const (
|
||||
stopSnippet = "SnippetStop" // enough arrived, and the turn ended
|
||||
stopSnippetRead = "SnippetThenRead" // the snippet was short; the read finished it
|
||||
stopDefensiveRead = "DefensiveRead" // enough had arrived, one read confirmed it
|
||||
stopPostRetrieval = "PostSufficientRetrieval" // enough had arrived, and retrieval continued
|
||||
stopRunaway = "PostSufficientRunaway" // it continued for rounds, or ran out of budget
|
||||
stopNeverEnough = "NeverSufficient"
|
||||
)
|
||||
|
||||
// runawayRounds is where continuing stops looking like confirmation.
|
||||
const runawayRounds = 3
|
||||
|
||||
func stoppingClass(m contextMetrics) string {
|
||||
if m.FirstSufficientRound == 0 {
|
||||
return stopNeverEnough
|
||||
}
|
||||
after := m.SearchesAfterSufficient + m.ReadsAfterSufficient + m.EscapesAfterSufficient
|
||||
switch {
|
||||
case after == 0 && m.SufficientVia == "snippet":
|
||||
return stopSnippet
|
||||
case after == 0:
|
||||
return stopSnippetRead
|
||||
case m.RoundsAfterSufficient >= runawayRounds || m.FailureStage == "RunError":
|
||||
return stopRunaway
|
||||
case after == 1 && m.ReadsAfterSufficient == 1:
|
||||
return stopDefensiveRead
|
||||
default:
|
||||
return stopPostRetrieval
|
||||
}
|
||||
}
|
||||
|
||||
// reportStopping is the funnel that matters once retrieval succeeds: what the
|
||||
// turn did after it already had everything it needed.
|
||||
func reportStopping(all []contextMetrics) string {
|
||||
classes := map[string]int{}
|
||||
var extraRounds, extraTokens []float64
|
||||
sufficient, answeredNext := 0, 0
|
||||
for _, m := range all {
|
||||
if m.Failed || m.Contaminated {
|
||||
continue
|
||||
}
|
||||
classes[m.StoppingClass]++
|
||||
if m.FirstSufficientRound == 0 {
|
||||
continue
|
||||
}
|
||||
sufficient++
|
||||
extraRounds = append(extraRounds, float64(m.RoundsAfterSufficient))
|
||||
extraTokens = append(extraTokens, float64(m.RecallTokensAfterSufficient))
|
||||
if m.RoundsAfterSufficient <= 1 {
|
||||
answeredNext++
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\n## After the evidence was already enough\n")
|
||||
fmt.Fprintf(&b, " sufficient in %d runs; ended within one round in %d\n", sufficient, answeredNext)
|
||||
fmt.Fprintf(&b, " extra rounds mean %.1f median %.1f p90 %.0f max %.0f\n",
|
||||
mean(extraRounds), median(extraRounds), percentile(extraRounds, 0.9), maxOf(extraRounds))
|
||||
fmt.Fprintf(&b, " extra recall tokens mean %.0f median %.0f p90 %.0f max %.0f\n",
|
||||
mean(extraTokens), median(extraTokens), percentile(extraTokens, 0.9), maxOf(extraTokens))
|
||||
b.WriteString(" " + countsLine(classes) + "\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// The scorer decides what the pilot concludes, so it is held to the same rule
|
||||
// the corpus is: judgements read structure. A model that says it will search
|
||||
// has not searched; a recall call carrying a query has.
|
||||
|
||||
const scorerTarget = 42
|
||||
|
||||
func recallCall(id, args string) provider.Message {
|
||||
return provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
|
||||
{ID: id, Name: "recall", Arguments: args},
|
||||
}}
|
||||
}
|
||||
|
||||
func toolResult(id, text string) provider.Message {
|
||||
return provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "recall", Content: text}
|
||||
}
|
||||
|
||||
func hitBlock(positions ...int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Folded context matching \"x\":\n")
|
||||
for _, p := range positions {
|
||||
fmt.Fprintf(&b, "\n#%d assistant_text\nsome snippet here\n", p)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func scorerTask() fixtureInstance {
|
||||
return fixtureInstance{Task: contextTask{ID: "t"}, AnswerMarkers: []string{"cobalt-lark-17"}}
|
||||
}
|
||||
|
||||
func score(t *testing.T, msgs []provider.Message, final string) contextMetrics {
|
||||
t.Helper()
|
||||
m := scoreRun(msgs, scorerTask(), scorerTarget, "arm", false)
|
||||
m.scoreAnswer(final, scorerTask())
|
||||
return m
|
||||
}
|
||||
|
||||
// The whole funnel, walked end to end.
|
||||
func TestScorerReadsTheFullRetrievalChain(t *testing.T) {
|
||||
m := score(t, []provider.Message{
|
||||
recallCall("c1", `{"query":"retry boundary"}`),
|
||||
toolResult("c1", hitBlock(7, scorerTarget)),
|
||||
recallCall("c2", fmt.Sprintf(`{"positions":[%d]}`, scorerTarget)),
|
||||
toolResult("c2", "#42\n[assistant]\nthe ownership token is cobalt-lark-17"),
|
||||
{Role: provider.RoleAssistant, Content: "The token was cobalt-lark-17."},
|
||||
}, "The token was cobalt-lark-17.")
|
||||
|
||||
if m.SearchCalls != 1 || m.ReadCalls != 1 {
|
||||
t.Errorf("calls = %d search / %d read, want 1/1", m.SearchCalls, m.ReadCalls)
|
||||
}
|
||||
if m.TargetSearchHits != 1 || m.FirstTargetRank != 2 {
|
||||
t.Errorf("target hit %d at rank %d, want 1 at rank 2", m.TargetSearchHits, m.FirstTargetRank)
|
||||
}
|
||||
if !m.TargetRead || !m.ReadAfterHit || m.RecallReadWithoutSearch {
|
||||
t.Errorf("read flags = read %v afterHit %v direct %v", m.TargetRead, m.ReadAfterHit, m.RecallReadWithoutSearch)
|
||||
}
|
||||
if !m.AnswerRecovered || m.FailureStage != stageRecovered {
|
||||
t.Errorf("stage = %q recovered=%v, want Recovered", m.FailureStage, m.AnswerRecovered)
|
||||
}
|
||||
if m.RecallReturnedTokens == 0 {
|
||||
t.Error("recall returned tokens were not counted")
|
||||
}
|
||||
}
|
||||
|
||||
// An index arm's claim: the address was already on screen, so no search was
|
||||
// needed. That is a different outcome from finding it, and gets its own stage.
|
||||
func TestScorerSeparatesCueReadFromSearch(t *testing.T) {
|
||||
m := score(t, []provider.Message{
|
||||
recallCall("c1", fmt.Sprintf(`{"positions":[%d]}`, scorerTarget)),
|
||||
toolResult("c1", "#42\nthe ownership token is cobalt-lark-17"),
|
||||
{Role: provider.RoleAssistant, Content: "cobalt-lark-17"},
|
||||
}, "cobalt-lark-17")
|
||||
|
||||
if m.SearchCalls != 0 || !m.RecallReadWithoutSearch {
|
||||
t.Errorf("search=%d direct=%v, want 0 and true", m.SearchCalls, m.RecallReadWithoutSearch)
|
||||
}
|
||||
if m.FailureStage != stageCueRead {
|
||||
t.Errorf("stage = %q, want %q", m.FailureStage, stageCueRead)
|
||||
}
|
||||
}
|
||||
|
||||
// Every way the chain can break gets its own label, so a failed run says where
|
||||
// rather than only that.
|
||||
func TestScorerNamesWhereTheChainBroke(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
msgs []provider.Message
|
||||
final string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "never touched recall",
|
||||
msgs: []provider.Message{{Role: provider.RoleAssistant, Content: "I do not recall that."}},
|
||||
final: "I do not recall that.",
|
||||
want: stageNoRetrieval,
|
||||
},
|
||||
{
|
||||
name: "searched and missed",
|
||||
msgs: []provider.Message{
|
||||
recallCall("c1", `{"query":"unrelated words"}`),
|
||||
toolResult("c1", hitBlock(3, 9)),
|
||||
},
|
||||
final: "not found",
|
||||
want: stageSearchMiss,
|
||||
},
|
||||
{
|
||||
name: "found it and never opened it",
|
||||
msgs: []provider.Message{
|
||||
recallCall("c1", `{"query":"retry boundary"}`),
|
||||
toolResult("c1", hitBlock(scorerTarget)),
|
||||
},
|
||||
final: "probably something about retries",
|
||||
want: stageHitNotRead,
|
||||
},
|
||||
{
|
||||
name: "read it and answered wrong",
|
||||
msgs: []provider.Message{
|
||||
recallCall("c1", fmt.Sprintf(`{"positions":[%d]}`, scorerTarget)),
|
||||
toolResult("c1", "#42\nthe token is elsewhere"),
|
||||
},
|
||||
final: "I think it was amber-9",
|
||||
want: stageAnswerWrong,
|
||||
},
|
||||
{
|
||||
name: "read the wrong position",
|
||||
msgs: []provider.Message{
|
||||
recallCall("c1", `{"positions":[7]}`),
|
||||
toolResult("c1", "#7\nnothing useful"),
|
||||
},
|
||||
final: "unclear",
|
||||
want: stageReadMissed,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := score(t, tc.msgs, tc.final).FailureStage; got != tc.want {
|
||||
t.Errorf("stage = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The answer lives only in folded history, so reaching for the workspace is a
|
||||
// strategy change worth seeing — including when it happens to work.
|
||||
func TestScorerRecordsWorkspaceEscapes(t *testing.T) {
|
||||
m := score(t, []provider.Message{
|
||||
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
|
||||
{ID: "g1", Name: "bash", Arguments: `{"command":"rg cobalt-lark"}`},
|
||||
{ID: "g2", Name: "read_file", Arguments: `{"path":"notes.md"}`},
|
||||
}},
|
||||
{Role: provider.RoleAssistant, Content: "cobalt-lark-17"},
|
||||
}, "cobalt-lark-17")
|
||||
|
||||
if len(m.UnexpectedWorkTools) != 2 {
|
||||
t.Fatalf("escapes = %v, want bash and read_file", m.UnexpectedWorkTools)
|
||||
}
|
||||
if m.SearchCalls != 0 || m.ReadCalls != 0 {
|
||||
t.Errorf("a workspace tool was counted as recall: search=%d read=%d", m.SearchCalls, m.ReadCalls)
|
||||
}
|
||||
// It answered, so the run is Recovered — the escape is reported beside the
|
||||
// outcome rather than overriding it.
|
||||
if !m.AnswerRecovered {
|
||||
t.Error("a correct answer was not scored as recovered")
|
||||
}
|
||||
}
|
||||
|
||||
// Narration is not action.
|
||||
func TestScorerIgnoresWhatTheModelSaysItWillDo(t *testing.T) {
|
||||
m := score(t, []provider.Message{
|
||||
{Role: provider.RoleAssistant, Content: "Let me search the folded context with recall for the retry boundary."},
|
||||
}, "Let me search the folded context with recall for the retry boundary.")
|
||||
if m.SearchCalls != 0 || m.FailureStage != stageNoRetrieval {
|
||||
t.Errorf("prose was scored as a search: calls=%d stage=%q", m.SearchCalls, m.FailureStage)
|
||||
}
|
||||
}
|
||||
|
||||
// Every marker must land, so a partially right answer is not a recovery.
|
||||
func TestScorerRequiresEveryMarker(t *testing.T) {
|
||||
task := fixtureInstance{Task: contextTask{ID: "t"}, AnswerMarkers: []string{"comet-42", "per-lineage"}}
|
||||
m := scoreRun(nil, task, scorerTarget, "arm", false)
|
||||
m.scoreAnswer("the salt was comet-42", task)
|
||||
if m.AnswerRecovered {
|
||||
t.Error("a half answer scored as recovered")
|
||||
}
|
||||
if len(m.MissingMarkers) != 1 || m.MissingMarkers[0] != "per-lineage" {
|
||||
t.Errorf("missing = %v, want per-lineage", m.MissingMarkers)
|
||||
}
|
||||
}
|
||||
|
||||
// An answer reachable through anything but recall invalidates the run. The
|
||||
// workspace is isolated so this should never fire; asserting it beats trusting
|
||||
// it, and a tripped run leaves the statistics rather than inflating one.
|
||||
func TestScorerInvalidatesRunsThatLeakedThroughAnotherTool(t *testing.T) {
|
||||
m := score(t, []provider.Message{
|
||||
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
|
||||
{ID: "g1", Name: "bash", Arguments: `{"command":"rg -r ."}`},
|
||||
}},
|
||||
{Role: provider.RoleTool, ToolCallID: "g1", Name: "bash", Content: "corpus.go: the token is cobalt-lark-17"},
|
||||
{Role: provider.RoleAssistant, Content: "cobalt-lark-17"},
|
||||
}, "cobalt-lark-17")
|
||||
|
||||
if !m.Contaminated || m.LeakedVia != "bash" || m.LeakedMarker != "cobalt-lark-17" {
|
||||
t.Fatalf("leak not caught: contaminated=%v via=%q marker=%q", m.Contaminated, m.LeakedVia, m.LeakedMarker)
|
||||
}
|
||||
if m.FailureStage != stageContaminated {
|
||||
t.Errorf("stage = %q, want %q even though every marker matched", m.FailureStage, stageContaminated)
|
||||
}
|
||||
f := summarize("arm", []contextMetrics{m})
|
||||
if f.Scored != 0 || f.Answered != 0 || f.Contaminated != 1 {
|
||||
t.Errorf("a contaminated run entered the statistics: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// Where an escape happened matters: before any recall is a different strategy
|
||||
// from after one came back short.
|
||||
func TestScorerPlacesEscapesRelativeToRecall(t *testing.T) {
|
||||
m := score(t, []provider.Message{
|
||||
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
|
||||
{ID: "g1", Name: "bash", Arguments: `{"command":"ls"}`},
|
||||
}},
|
||||
{Role: provider.RoleTool, ToolCallID: "g1", Name: "bash", Content: "README.md"},
|
||||
recallCall("c1", `{"query":"retry boundary"}`),
|
||||
toolResult("c1", hitBlock(scorerTarget)),
|
||||
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
|
||||
{ID: "g2", Name: "read_file", Arguments: `{"path":"README.md"}`},
|
||||
}},
|
||||
{Role: provider.RoleTool, ToolCallID: "g2", Name: "read_file", Content: "nothing here"},
|
||||
}, "unclear")
|
||||
|
||||
if m.EscapeCalls != 2 || m.EscapeBeforeFirstRecall != 1 || m.EscapeAfterFirstRecall != 1 {
|
||||
t.Errorf("escapes = %d (%d before, %d after), want 2 (1, 1)",
|
||||
m.EscapeCalls, m.EscapeBeforeFirstRecall, m.EscapeAfterFirstRecall)
|
||||
}
|
||||
}
|
||||
|
||||
// Routing is judged by round. Two calls in one round are a fan-out, and
|
||||
// reporting that as a preference would invent a decision the model never made.
|
||||
func TestRoutingIsJudgedByRoundNotEventOrder(t *testing.T) {
|
||||
parallel := []provider.Message{
|
||||
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
|
||||
{ID: "g1", Name: "bash", Arguments: `{"command":"ls"}`},
|
||||
{ID: "c1", Name: "recall", Arguments: `{"query":"retry boundary"}`},
|
||||
}},
|
||||
{Role: provider.RoleTool, ToolCallID: "g1", Name: "bash", Content: "README.md"},
|
||||
toolResult("c1", hitBlock(scorerTarget)),
|
||||
}
|
||||
if got := score(t, parallel, "unclear").Routing; got != routeParallel {
|
||||
t.Errorf("routing = %q for one round holding both, want %q", got, routeParallel)
|
||||
}
|
||||
|
||||
workspaceFirst := []provider.Message{
|
||||
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
|
||||
{ID: "g1", Name: "bash", Arguments: `{"command":"ls"}`},
|
||||
}},
|
||||
{Role: provider.RoleTool, ToolCallID: "g1", Name: "bash", Content: "README.md"},
|
||||
recallCall("c1", `{"query":"retry boundary"}`),
|
||||
toolResult("c1", hitBlock(scorerTarget)),
|
||||
}
|
||||
if got := score(t, workspaceFirst, "unclear").Routing; got != routeWorkspaceFirst {
|
||||
t.Errorf("routing = %q for workspace in an earlier round, want %q", got, routeWorkspaceFirst)
|
||||
}
|
||||
|
||||
memoryFirst := []provider.Message{
|
||||
recallCall("c1", `{"query":"retry boundary"}`),
|
||||
toolResult("c1", hitBlock(scorerTarget)),
|
||||
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
|
||||
{ID: "g1", Name: "bash", Arguments: `{"command":"ls"}`},
|
||||
}},
|
||||
{Role: provider.RoleTool, ToolCallID: "g1", Name: "bash", Content: "README.md"},
|
||||
}
|
||||
if got := score(t, memoryFirst, "unclear").Routing; got != routeMemoryFirst {
|
||||
t.Errorf("routing = %q for recall in an earlier round, want %q", got, routeMemoryFirst)
|
||||
}
|
||||
|
||||
if got := score(t, nil, "no idea").Routing; got != routeNeither {
|
||||
t.Errorf("routing = %q with no tools at all, want %q", got, routeNeither)
|
||||
}
|
||||
}
|
||||
|
||||
// Markers matching proves the right fact appears somewhere. An answer that
|
||||
// hedges between two candidates contains it and has not submitted it.
|
||||
func TestScorerFlagsAnAnswerThatNamesTwoCandidates(t *testing.T) {
|
||||
inst := fixtureInstance{
|
||||
Task: contextTask{ID: "t", Vars: []varSpec{code("marker", "fence")}},
|
||||
AnswerMarkers: []string{"fence-qmwnv"},
|
||||
}
|
||||
m := scoreRun(nil, inst, 0, "arm", false)
|
||||
m.scoreAnswer("It was fence-qmwnv, though it may have been fence-abcde.", inst)
|
||||
if !m.AnswerRecovered {
|
||||
t.Error("the marker is present; recovery should still read true")
|
||||
}
|
||||
if !m.AnswerAmbiguous || m.AmbiguousVar != "marker" {
|
||||
t.Errorf("ambiguity not flagged: %v %q", m.AnswerAmbiguous, m.AmbiguousVar)
|
||||
}
|
||||
|
||||
clean := scoreRun(nil, inst, 0, "arm", false)
|
||||
clean.scoreAnswer("The marker was fence-qmwnv.", inst)
|
||||
if clean.AnswerAmbiguous {
|
||||
t.Error("a single candidate was called ambiguous")
|
||||
}
|
||||
}
|
||||
|
||||
// The index arm's claim is narrow: the cue was addressed, the first memory
|
||||
// action was a positions read, and it covered the target. A positions call
|
||||
// after a search located the target credits the index for the search's work.
|
||||
func TestCueDirectReadRequiresTheCueAndTheFirstAction(t *testing.T) {
|
||||
direct := []provider.Message{
|
||||
recallCall("c1", fmt.Sprintf(`{"positions":[%d]}`, scorerTarget)),
|
||||
toolResult("c1", "#42\nthe token is cobalt-lark-17"),
|
||||
}
|
||||
m := scoreRun(direct, scorerTask(), scorerTarget, "arm", true)
|
||||
if !m.CueDirectRead {
|
||||
t.Error("a first-action read with the cue visible was not credited")
|
||||
}
|
||||
|
||||
// Same read, but no cue was on screen to give the address.
|
||||
if scoreRun(direct, scorerTask(), scorerTarget, "arm", false).CueDirectRead {
|
||||
t.Error("a read was credited to a cue that was not visible")
|
||||
}
|
||||
|
||||
// Same read, but a search found the address first.
|
||||
afterSearch := append([]provider.Message{
|
||||
recallCall("c0", `{"query":"retry boundary"}`),
|
||||
toolResult("c0", hitBlock(scorerTarget)),
|
||||
}, direct...)
|
||||
if scoreRun(afterSearch, scorerTask(), scorerTarget, "arm", true).CueDirectRead {
|
||||
t.Error("a read after a search was credited to the index")
|
||||
}
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// Synthetic history on purpose: an answer that exists in the workspace is
|
||||
// solvable by reading the workspace, which measures grep. Every answer is a
|
||||
// placeholder here and a value only inside one run.
|
||||
|
||||
// experiment names which question a task belongs to.
|
||||
const (
|
||||
experimentSearch = "search" // does search reach what no index addresses?
|
||||
experimentIndex = "index" // what is an ambient address line worth?
|
||||
)
|
||||
|
||||
// cueTier is the narrowest fold-index scale at which an index task's cue is
|
||||
// still addressed. Below it the model has to find the call without a hint.
|
||||
const (
|
||||
tierQuarter = "quarter" // visible at default, half, quarter
|
||||
tierHalf = "half" // visible at default, half
|
||||
tierDefault = "default" // visible at default only
|
||||
)
|
||||
|
||||
// role places an index task in the efficiency substrate or the stopping corpus.
|
||||
const (
|
||||
roleEfficiency = "efficiency"
|
||||
roleStopping = "stopping"
|
||||
)
|
||||
|
||||
// plantKind is how a task's target enters the transcript.
|
||||
const (
|
||||
plantAssistant = "assistant" // the model's own account: never index-addressed
|
||||
plantCall = "call" // a tool call and its result: always index-addressed
|
||||
)
|
||||
|
||||
// contextTask is one recall question as a template. Nothing here is an answer.
|
||||
type contextTask struct {
|
||||
ID string
|
||||
Experiment string
|
||||
TargetKind string
|
||||
Prompt string
|
||||
ProbeQuery string
|
||||
// Vars declare the placeholders. AnswerVars name the ones a correct answer
|
||||
// must contain, CueVar the one an index line carries.
|
||||
Vars []varSpec
|
||||
AnswerVars []string
|
||||
CueVar string
|
||||
CueTier string
|
||||
// Role is roleEfficiency or roleStopping; it decides the batch a task enters.
|
||||
Role string
|
||||
// PlantAfterGen ages the target: mergeFoldIndex trims from its oldest end,
|
||||
// so what follows a cue decides which budget addresses it. Frozen, because
|
||||
// a run that searched for it would let the tested code move its goalposts.
|
||||
PlantAfterGen int
|
||||
// Plant is how the target enters, and the rest is what it says.
|
||||
Plant string
|
||||
CallTool string
|
||||
CallArgs string
|
||||
CallID string
|
||||
PlantBody string
|
||||
}
|
||||
|
||||
// fixtureInstance is one task with values. Scoring binds to this, never to the
|
||||
// corpus: the corpus has no answers to bind to.
|
||||
type fixtureInstance struct {
|
||||
Task contextTask
|
||||
Vars fixtureVars
|
||||
Prompt string
|
||||
ProbeQuery string
|
||||
AnswerMarkers []string
|
||||
CueMarker string
|
||||
body string
|
||||
callArgs string
|
||||
}
|
||||
|
||||
// instantiateTask resolves one task against a generator.
|
||||
func instantiateTask(t contextTask, rng *rand.Rand) (fixtureInstance, error) {
|
||||
vars := newVars(t.Vars, rng)
|
||||
inst := fixtureInstance{Task: t, Vars: vars}
|
||||
var err error
|
||||
for _, pair := range []struct {
|
||||
dst *string
|
||||
text string
|
||||
}{
|
||||
{&inst.Prompt, t.Prompt},
|
||||
{&inst.ProbeQuery, t.ProbeQuery},
|
||||
{&inst.body, t.PlantBody},
|
||||
{&inst.callArgs, t.CallArgs},
|
||||
} {
|
||||
if *pair.dst, err = instantiate(pair.text, vars); err != nil {
|
||||
return fixtureInstance{}, fmt.Errorf("%s: %w", t.ID, err)
|
||||
}
|
||||
}
|
||||
for _, name := range t.AnswerVars {
|
||||
value, ok := vars[name]
|
||||
if !ok {
|
||||
return fixtureInstance{}, fmt.Errorf("%s: answer var %q is not declared", t.ID, name)
|
||||
}
|
||||
inst.AnswerMarkers = append(inst.AnswerMarkers, value)
|
||||
}
|
||||
if t.CueVar != "" {
|
||||
inst.CueMarker = vars[t.CueVar]
|
||||
}
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
// plant appends the instantiated target and returns its canonical position.
|
||||
func (f fixtureInstance) plant(s *sessionstore.Session) int {
|
||||
at := len(s.Messages)
|
||||
if f.Task.Plant == plantAssistant {
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: f.body})
|
||||
return at
|
||||
}
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
|
||||
{ID: f.Task.CallID, Name: f.Task.CallTool, Arguments: f.callArgs},
|
||||
}})
|
||||
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: f.Task.CallID, Name: f.Task.CallTool, Content: f.body})
|
||||
return at
|
||||
}
|
||||
|
||||
func code(name, word string) varSpec { return varSpec{Name: name, Kind: varCodename, Word: word} }
|
||||
|
||||
func num(name string, lo, hi int) varSpec {
|
||||
return varSpec{Name: name, Kind: varInt, Min: lo, Max: hi}
|
||||
}
|
||||
|
||||
// efficiencyTasks is the substrate a study measures on. Every task in it
|
||||
// qualifies under the corpus bar; the stopping corpus is deliberately excluded,
|
||||
// because a task carrying post-sufficient stopping behaviour swamps the effect
|
||||
// an efficiency batch exists to measure.
|
||||
func efficiencyTasks() []contextTask {
|
||||
var out []contextTask
|
||||
for _, t := range indexTasks() {
|
||||
if t.Role == roleEfficiency {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func searchTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "r01-retry-boundary", Experiment: experimentSearch, TargetKind: "assistant_reasoning",
|
||||
Prompt: "What ownership token did we settle on for the retry boundary, and under what tag did we file the reason for not moving retries above framing?",
|
||||
ProbeQuery: "retry boundary ownership token framing",
|
||||
Vars: []varSpec{code("token", "cobalt"), code("reason", "stream")},
|
||||
AnswerVars: []string{"token", "reason"},
|
||||
Plant: plantAssistant,
|
||||
PlantBody: "The retry boundary remains at ingress. The ownership token is {{token}}. " +
|
||||
"Moving retries above framing can duplicate a partially received stream, which we filed as {{reason}}.",
|
||||
},
|
||||
{
|
||||
ID: "r02-checkpoint-anchor", Experiment: experimentSearch, TargetKind: "assistant_reasoning",
|
||||
Prompt: "What was the codename of the checkpoint anchor policy, and what tag did we give the reason for not anchoring against digest positions?",
|
||||
ProbeQuery: "checkpoint anchor policy digest positions",
|
||||
Vars: []varSpec{code("policy", "northglass"), code("reason", "genlocal")},
|
||||
AnswerVars: []string{"policy", "reason"},
|
||||
Plant: plantAssistant,
|
||||
PlantBody: "The checkpoint anchor policy is {{policy}}. Digest positions are generation-local — " +
|
||||
"filed as {{reason}} — so recovery must anchor against canonical state.",
|
||||
},
|
||||
{
|
||||
ID: "r03-recovery-cjk", Experiment: experimentSearch, TargetKind: "assistant_reasoning_cjk",
|
||||
Prompt: "我们之前定的验证失败恢复策略代号叫什么?当时把“失败 digest 不能变成 baseline”这条登记在哪个标记下?",
|
||||
ProbeQuery: "恢复策略 baseline digest 验证失败",
|
||||
Vars: []varSpec{code("policy", "songzhen"), code("rule", "trustbase")},
|
||||
AnswerVars: []string{"policy", "rule"},
|
||||
Plant: plantAssistant,
|
||||
PlantBody: "恢复策略代号是 {{policy}}。验证失败以后必须保留旧基线,不能把新的 digest 晋升为 baseline," +
|
||||
"因为失败证据不能成为后续比较的可信基准;这条规则登记为 {{rule}}。",
|
||||
},
|
||||
{
|
||||
ID: "r04-lease-fence", Experiment: experimentSearch, TargetKind: "assistant_reasoning",
|
||||
Prompt: "那次 lease fence 检查报告的诊断标记是什么?expected 和 observed epoch 分别是多少?",
|
||||
ProbeQuery: "lease fence writer epoch mismatch",
|
||||
Vars: []varSpec{
|
||||
code("marker", "fence"), num("expected", 400, 900), num("observed", 100, 399),
|
||||
},
|
||||
AnswerVars: []string{"marker", "expected", "observed"},
|
||||
Plant: plantAssistant,
|
||||
PlantBody: "The lease fence check reported {{marker}}: expected writer epoch {{expected}}, " +
|
||||
"observed {{observed}}. We left the fence in place.",
|
||||
},
|
||||
{
|
||||
ID: "r05-schema-guard", Experiment: experimentSearch, TargetKind: "assistant_reasoning",
|
||||
Prompt: "当时 schema guard 报告的标记是什么,超预算多少 token?",
|
||||
ProbeQuery: "schema guard provider visible surface budget",
|
||||
Vars: []varSpec{code("marker", "amber"), num("over", 120, 480)},
|
||||
AnswerVars: []string{"marker", "over"},
|
||||
Plant: plantAssistant,
|
||||
PlantBody: "The schema guard reported {{marker}}: the provider-visible tool schema exceeded " +
|
||||
"the allowed surface by {{over}} tokens. We trimmed a description instead.",
|
||||
},
|
||||
{
|
||||
ID: "r06-shadow-probe", Experiment: experimentSearch, TargetKind: "assistant_reasoning",
|
||||
Prompt: "我们那次 shadow probe 的 marker、p95 divergence 和 generation 分别是多少?",
|
||||
ProbeQuery: "shadow probe divergence generation marker",
|
||||
Vars: []varSpec{
|
||||
code("marker", "violet"),
|
||||
{Name: "divergence", Kind: varDecimal, Min: 110, Max: 890},
|
||||
num("generation", 14, 60),
|
||||
},
|
||||
AnswerVars: []string{"marker", "divergence", "generation"},
|
||||
Plant: plantAssistant,
|
||||
PlantBody: "The shadow probe came back as marker={{marker}} with a p95 divergence of " +
|
||||
"{{divergence}} at generation={{generation}}, which we judged within tolerance.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// efficiencyIndexTasks is what a study measures on: two tasks per cue tier.
|
||||
func efficiencyIndexTasks() []contextTask {
|
||||
out := indexQuarterTasks()
|
||||
out = append(out, indexHalfTasks()...)
|
||||
out = append(out, indexDefaultTasks()...)
|
||||
return out
|
||||
}
|
||||
|
||||
// indexQuarterTasks holds the quarter-tier literals.
|
||||
func indexQuarterTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i37-dispatchlag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/dispatchlag,它打印出的 dispatch lag epoch 是多少?",
|
||||
ProbeQuery: "dispatchlag dispatch lag epoch",
|
||||
Vars: []varSpec{code("cue", "dispatchlag"), code("epoch", "cobalt")},
|
||||
AnswerVars: []string{"epoch"}, CueVar: "cue", CueTier: tierQuarter,
|
||||
PlantAfterGen: 4,
|
||||
Plant: plantCall, CallID: "p37", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/dispatchlag {{cue}}"}`,
|
||||
PlantBody: "dispatch lag epoch = {{epoch}}",
|
||||
},
|
||||
{
|
||||
ID: "i118-fencelag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里运行过 ./scripts/fencelag,它打印出的 fence lag 是多少?",
|
||||
ProbeQuery: "fencelag fence lag",
|
||||
Vars: []varSpec{code("cue", "fencelag"), code("v", "slate")},
|
||||
AnswerVars: []string{"v"}, CueVar: "cue", CueTier: tierQuarter,
|
||||
PlantAfterGen: 4,
|
||||
Plant: plantCall, CallID: "x118", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/fencelag {{cue}}"}`,
|
||||
PlantBody: "fence lag = {{v}}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// indexHalfTasks holds the half-tier literals.
|
||||
func indexHalfTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i70-ingestfloor", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/ingestfloor,它打印出的 route quota 是多少?",
|
||||
ProbeQuery: "ingestfloor quota",
|
||||
Vars: []varSpec{code("cue", "ingestfloor"), num("quota", 1200, 9800)},
|
||||
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
|
||||
PlantAfterGen: 2,
|
||||
Plant: plantCall, CallID: "q70", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/ingestfloor {{cue}}"}`,
|
||||
PlantBody: "route quota = {{quota}}",
|
||||
},
|
||||
{
|
||||
ID: "i106-budgetseal", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/budgetseal,它打印出的 route quota 是多少?",
|
||||
ProbeQuery: "budgetseal route quota",
|
||||
Vars: []varSpec{code("cue", "budgetseal"), code("quota", "amber")},
|
||||
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
|
||||
PlantAfterGen: 2,
|
||||
Plant: plantCall, CallID: "h106", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/budgetseal {{cue}}"}`,
|
||||
PlantBody: "route quota = {{quota}}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// indexDefaultTasks holds the default-tier literals.
|
||||
func indexDefaultTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i47-tenantlease", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/tenantlease,它打印出的 tenant lease(秒)是多少?",
|
||||
ProbeQuery: "tenantlease tenant lease",
|
||||
Vars: []varSpec{code("cue", "tenantlease"), {Name: "lease", Kind: varDuration, Min: 15, Max: 300}},
|
||||
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
|
||||
PlantAfterGen: 0,
|
||||
Plant: plantCall, CallID: "p47", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/tenantlease {{cue}}"}`,
|
||||
PlantBody: "tenant lease = {{lease}}",
|
||||
},
|
||||
{
|
||||
ID: "i112-lagquota", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/lagquota,它打印出的 lagquota lease 是多少?",
|
||||
ProbeQuery: "lagquota lagquota lease",
|
||||
Vars: []varSpec{code("cue", "lagquota"), {Name: "lease", Kind: varDuration, Min: 20, Max: 420}},
|
||||
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
|
||||
PlantAfterGen: 0,
|
||||
Plant: plantCall, CallID: "d112", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/lagquota {{cue}}"}`,
|
||||
PlantBody: "lagquota lease = {{lease}}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// stoppingIndexTasks is the frozen study's six tasks, kept out of efficiency
|
||||
// batches and measurable on their own with -task <id>.
|
||||
func stoppingIndexTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i01-transport-fallback", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
Prompt: "transport fallback probe 当时报告的 selector 是什么?",
|
||||
ProbeQuery: "transport fallback probe selector",
|
||||
Vars: []varSpec{code("cue", "tfprobe"), code("selector", "fern")},
|
||||
AnswerVars: []string{"selector"}, CueVar: "cue", CueTier: tierQuarter,
|
||||
PlantAfterGen: 4,
|
||||
Plant: plantCall, CallID: "tf1", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/probe {{cue}}"}`,
|
||||
PlantBody: "fallback selector = {{selector}}",
|
||||
},
|
||||
{
|
||||
ID: "i02-scheduler-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
Prompt: "scheduler handoff 当时记录的 grace 和 marker 是什么?",
|
||||
ProbeQuery: "scheduler handoff grace marker",
|
||||
Vars: []varSpec{
|
||||
code("cue", "schedhandoff"), code("marker", "opal"),
|
||||
{Name: "grace", Kind: varDuration, Min: 18, Max: 90},
|
||||
},
|
||||
AnswerVars: []string{"grace", "marker"}, CueVar: "cue", CueTier: tierQuarter,
|
||||
PlantAfterGen: 4,
|
||||
Plant: plantCall, CallID: "sh1", CallTool: "read_file",
|
||||
CallArgs: `{"path":"scratch/{{cue}}.txt"}`,
|
||||
PlantBody: "handoff grace = {{grace}}\nmarker = {{marker}}",
|
||||
},
|
||||
{
|
||||
ID: "i03-coalescing", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
Prompt: "job completion 的 coalescing probe 最后记录的 window 和 marker 是多少?",
|
||||
ProbeQuery: "coalescing policy completion window",
|
||||
Vars: []varSpec{
|
||||
code("cue", "coalescing"), code("marker", "cedar"),
|
||||
{Name: "window", Kind: varDuration, Min: 40, Max: 140},
|
||||
},
|
||||
AnswerVars: []string{"window", "marker"}, CueVar: "cue", CueTier: tierHalf,
|
||||
PlantAfterGen: 2,
|
||||
Plant: plantCall, CallID: "cp1", CallTool: "bash",
|
||||
CallArgs: `{"command":"grep {{cue}} scratch/job-notes.log"}`,
|
||||
PlantBody: "completion coalesce window = {{window}}\nmarker = {{marker}}",
|
||||
},
|
||||
{
|
||||
ID: "i04-recovery-fence", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
Prompt: "recovery fence 那次检查报告的 marker 和两个 epoch 是什么?",
|
||||
ProbeQuery: "recovery fence epoch check",
|
||||
Vars: []varSpec{
|
||||
code("cue", "recfence"), code("marker", "quartz"),
|
||||
num("expected", 400, 900), num("observed", 100, 399),
|
||||
},
|
||||
AnswerVars: []string{"marker", "expected", "observed"}, CueVar: "cue", CueTier: tierHalf,
|
||||
PlantAfterGen: 2,
|
||||
Plant: plantCall, CallID: "rf1", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/check {{cue}}"}`,
|
||||
PlantBody: "{{marker}}\nexpected epoch {{expected}}\nobserved epoch {{observed}}",
|
||||
},
|
||||
{
|
||||
ID: "i05-cache-probe", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
Prompt: "cache probe 当时记录的 prefix salt 和 scope 是什么?",
|
||||
ProbeQuery: "cache probe prefix salt scope",
|
||||
Vars: []varSpec{code("cue", "cacheprobe"), code("salt", "comet"), code("scope", "lineage")},
|
||||
AnswerVars: []string{"salt", "scope"}, CueVar: "cue", CueTier: tierDefault,
|
||||
PlantAfterGen: 0,
|
||||
Plant: plantCall, CallID: "cb1", CallTool: "read_file",
|
||||
CallArgs: `{"path":"scratch/{{cue}}.txt"}`,
|
||||
PlantBody: "prefix salt = {{salt}}\nscope = {{scope}}",
|
||||
},
|
||||
{
|
||||
ID: "i06-dispatch-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
Prompt: "dispatch handoff probe 当时确认的 mode 和 marker 是什么?",
|
||||
ProbeQuery: "dispatch handoff probe mode",
|
||||
Vars: []varSpec{code("cue", "dispatchhandoff"), code("mode", "trigger"), code("marker", "iris")},
|
||||
AnswerVars: []string{"mode", "marker"}, CueVar: "cue", CueTier: tierDefault,
|
||||
PlantAfterGen: 0,
|
||||
Plant: plantCall, CallID: "dh1", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/probe {{cue}}"}`,
|
||||
PlantBody: "handoff mode = {{mode}}\nmarker = {{marker}}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// indexTasks is the whole index corpus, the efficiency substrate first.
|
||||
func indexTasks() []contextTask {
|
||||
out := efficiencyIndexTasks()
|
||||
return append(out, stoppingIndexTasks()...)
|
||||
}
|
||||
|
||||
func allTasks() []contextTask {
|
||||
return append(searchTasks(), indexTasks()...)
|
||||
}
|
||||
|
||||
func taskByID(id string) (contextTask, bool) {
|
||||
for _, t := range allTasks() {
|
||||
if t.ID == id {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return contextTask{}, false
|
||||
}
|
||||
|
||||
// tierScales is the fixture's contract: which arms must address a cue and
|
||||
// which must not. A policy change that moves a cue across a boundary fails
|
||||
// preflight rather than turning two arms into one experiment.
|
||||
func tierScales(tier string) (visible, hidden []string) {
|
||||
switch tier {
|
||||
case tierQuarter:
|
||||
return []string{"default", "half", "quarter"}, []string{"off"}
|
||||
case tierHalf:
|
||||
return []string{"default", "half"}, []string{"quarter", "off"}
|
||||
case tierDefault:
|
||||
return []string{"default"}, []string{"half", "quarter", "off"}
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// boundaryPair is the one comparison an index task is built for: the narrowest
|
||||
// scale that still addresses its cue, and the next one down. Holding the
|
||||
// question fixed and moving only the affordance is what four arm averages
|
||||
// cannot do, since each of those mixes six different questions.
|
||||
func boundaryPair(tier string) (cue, noCue string) {
|
||||
switch tier {
|
||||
case tierQuarter:
|
||||
return "quarter", "off"
|
||||
case tierHalf:
|
||||
return "half", "quarter"
|
||||
case tierDefault:
|
||||
return "default", "half"
|
||||
default:
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
|
||||
func validateCorpus() error {
|
||||
seen := map[string]bool{}
|
||||
for _, t := range allTasks() {
|
||||
if seen[t.ID] {
|
||||
return fmt.Errorf("duplicate task id %q", t.ID)
|
||||
}
|
||||
seen[t.ID] = true
|
||||
if len(t.AnswerVars) == 0 || t.ProbeQuery == "" || t.Prompt == "" || t.PlantBody == "" {
|
||||
return fmt.Errorf("%s: a task needs a prompt, a probe query, a body and answer vars", t.ID)
|
||||
}
|
||||
if t.Experiment == experimentIndex {
|
||||
if t.CueVar == "" {
|
||||
return fmt.Errorf("%s: an index task needs a cue var", t.ID)
|
||||
}
|
||||
if t.Role != roleEfficiency && t.Role != roleStopping {
|
||||
return fmt.Errorf("%s: an index task needs a role (%s or %s)", t.ID, roleEfficiency, roleStopping)
|
||||
}
|
||||
if v, _ := tierScales(t.CueTier); v == nil {
|
||||
return fmt.Errorf("%s: unknown cue tier %q", t.ID, t.CueTier)
|
||||
}
|
||||
}
|
||||
if _, err := instantiateTask(t, seededRand(t.ID, "validate")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
// dose.go — does the cost step where the cue disappears?
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Not "a bigger index is better" — CueDirectRead says the addresses are rarely
|
||||
// used. The hypothesis is that a visible action cue lowers retrieval cost,
|
||||
// which predicts a step at each task's own boundary, not a slope.
|
||||
|
||||
// scaleOrder is the dose axis, widest first.
|
||||
var scaleOrder = []string{"default", "half", "quarter", "off"}
|
||||
|
||||
// boundaryStep is the index into the three transitions where a tier's cue
|
||||
// disappears: default->half is 0, half->quarter is 1, quarter->off is 2.
|
||||
func boundaryStep(tier string) int {
|
||||
switch tier {
|
||||
case tierDefault:
|
||||
return 0
|
||||
case tierHalf:
|
||||
return 1
|
||||
case tierQuarter:
|
||||
return 2
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
// doseCurve is one task's readings across the four budgets.
|
||||
type doseCurve struct {
|
||||
Task string
|
||||
Tier string
|
||||
Metric string
|
||||
Values [4]float64
|
||||
// Missing marks arms that produced nothing. Printing those as zero draws a
|
||||
// curve falling to the floor where in fact it was never measured.
|
||||
Missing [4]bool
|
||||
Present bool
|
||||
// PreMean averages the arms where the cue is visible, PostMean the rest.
|
||||
PreMean, PostMean float64
|
||||
BoundaryDelta float64
|
||||
// BoundaryAligned: the largest transition is the one where this task's cue
|
||||
// disappears. A change elsewhere is variance shaped like an effect.
|
||||
BoundaryAligned bool
|
||||
}
|
||||
|
||||
func buildCurve(task, tier, metric string, byScale map[string]float64) doseCurve {
|
||||
c := doseCurve{Task: task, Tier: tier, Metric: metric, Present: true}
|
||||
for i, scale := range scaleOrder {
|
||||
v, ok := byScale[scale]
|
||||
if !ok {
|
||||
c.Present, c.Missing[i] = false, true
|
||||
}
|
||||
c.Values[i] = v
|
||||
}
|
||||
step := boundaryStep(tier)
|
||||
if !c.Present || step < 0 {
|
||||
return c
|
||||
}
|
||||
visible := step + 1 // arms before the boundary still show the cue
|
||||
c.PreMean = mean(c.Values[:visible])
|
||||
c.PostMean = mean(c.Values[visible:])
|
||||
c.BoundaryDelta = c.PostMean - c.PreMean
|
||||
|
||||
transitions := [3]float64{
|
||||
math.Abs(c.Values[1] - c.Values[0]),
|
||||
math.Abs(c.Values[2] - c.Values[1]),
|
||||
math.Abs(c.Values[3] - c.Values[2]),
|
||||
}
|
||||
largest, at := 0.0, -1
|
||||
for i, t := range transitions {
|
||||
if t > largest {
|
||||
largest, at = t, i
|
||||
}
|
||||
}
|
||||
// A flat curve has no largest transition to align with anything.
|
||||
c.BoundaryAligned = largest > 0 && at == step
|
||||
return c
|
||||
}
|
||||
|
||||
func mean(xs []float64) float64 {
|
||||
if len(xs) == 0 {
|
||||
return 0
|
||||
}
|
||||
sum := 0.0
|
||||
for _, x := range xs {
|
||||
sum += x
|
||||
}
|
||||
return sum / float64(len(xs))
|
||||
}
|
||||
|
||||
func median(xs []float64) float64 {
|
||||
if len(xs) == 0 {
|
||||
return 0
|
||||
}
|
||||
s := append([]float64(nil), xs...)
|
||||
sort.Float64s(s)
|
||||
mid := len(s) / 2
|
||||
if len(s)%2 == 1 {
|
||||
return s[mid]
|
||||
}
|
||||
return (s[mid-1] + s[mid]) / 2
|
||||
}
|
||||
|
||||
// doseMetrics are the readings a curve is drawn for.
|
||||
var doseMetrics = []struct {
|
||||
name string
|
||||
of func(contextMetrics) float64
|
||||
}{
|
||||
{"searches", func(m contextMetrics) float64 { return float64(m.SearchCalls) }},
|
||||
{"recall-tokens", func(m contextMetrics) float64 { return float64(m.RecallReturnedTokens) }},
|
||||
{"tool-rounds", func(m contextMetrics) float64 { return float64(m.RetrievalRounds + m.EscapeCalls) }},
|
||||
{"escapes", func(m contextMetrics) float64 { return float64(m.EscapeCalls) }},
|
||||
}
|
||||
|
||||
// reportDoseResponse draws each task's curve and reports where the steps fell.
|
||||
func reportDoseResponse(all []contextMetrics) string {
|
||||
valid := map[string]map[string]contextMetrics{}
|
||||
for _, m := range all {
|
||||
if m.Failed || m.Contaminated || m.FailureStage == "RunError" {
|
||||
continue
|
||||
}
|
||||
scale := strings.TrimPrefix(m.Arm, "index-")
|
||||
if valid[m.Task] == nil {
|
||||
valid[m.Task] = map[string]contextMetrics{}
|
||||
}
|
||||
valid[m.Task][scale] = m
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("\n## Dose-response, per task\n")
|
||||
b.WriteString(" The arrow marks where this task's cue stops being addressed.\n")
|
||||
aligned := map[string]int{}
|
||||
counted := map[string]int{}
|
||||
deltas := map[string][]float64{}
|
||||
|
||||
for _, t := range indexTasks() {
|
||||
arms := valid[t.ID]
|
||||
if len(arms) == 0 {
|
||||
continue
|
||||
}
|
||||
step := boundaryStep(t.CueTier)
|
||||
fmt.Fprintf(&b, "\n %s (cue survives to %s)\n", t.ID, t.CueTier)
|
||||
fmt.Fprintf(&b, " %-14s%s\n", "", scaleHeader(step))
|
||||
for _, metric := range doseMetrics {
|
||||
byScale := map[string]float64{}
|
||||
for scale, m := range arms {
|
||||
byScale[scale] = metric.of(m)
|
||||
}
|
||||
c := buildCurve(t.ID, t.CueTier, metric.name, byScale)
|
||||
mark := " "
|
||||
if c.BoundaryAligned {
|
||||
mark = "*"
|
||||
aligned[metric.name]++
|
||||
}
|
||||
if c.Present {
|
||||
counted[metric.name]++
|
||||
deltas[metric.name] = append(deltas[metric.name], c.BoundaryDelta)
|
||||
}
|
||||
delta := fmt.Sprintf("Δ%+8.1f", c.BoundaryDelta)
|
||||
if !c.Present {
|
||||
delta = " no curve"
|
||||
}
|
||||
fmt.Fprintf(&b, " %-14s%s %s %s\n", metric.name, curveCells(c), delta, mark)
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n## Where the step fell (* = largest transition is at the cue boundary)\n")
|
||||
for _, metric := range doseMetrics {
|
||||
d := deltas[metric.name]
|
||||
sensitivity := ""
|
||||
if len(d) > 1 {
|
||||
sensitivity = fmt.Sprintf(" without the largest: %+.1f", meanWithoutLargest(d))
|
||||
}
|
||||
fmt.Fprintf(&b, " %-14s boundary-aligned %d/%d mean Δ%+.1f median Δ%+.1f%s\n",
|
||||
metric.name, aligned[metric.name], counted[metric.name], mean(d), median(d), sensitivity)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// meanWithoutLargest is the robustness check: the largest effect is reported
|
||||
// and then set aside, so a conclusion never rests on one task alone.
|
||||
func meanWithoutLargest(d []float64) float64 {
|
||||
if len(d) < 2 {
|
||||
return mean(d)
|
||||
}
|
||||
worst, at := 0.0, 0
|
||||
for i, v := range d {
|
||||
if math.Abs(v) > worst {
|
||||
worst, at = math.Abs(v), i
|
||||
}
|
||||
}
|
||||
rest := append(append([]float64(nil), d[:at]...), d[at+1:]...)
|
||||
return mean(rest)
|
||||
}
|
||||
|
||||
func scaleHeader(step int) string {
|
||||
var b strings.Builder
|
||||
for i, s := range scaleOrder {
|
||||
if i == step+1 {
|
||||
s = "↓" + s
|
||||
}
|
||||
fmt.Fprintf(&b, "%8s", s)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// curveCells prints a reading per arm, and a dash where an arm produced none.
|
||||
func curveCells(c doseCurve) string {
|
||||
var b strings.Builder
|
||||
for i, v := range c.Values {
|
||||
if c.Missing[i] {
|
||||
fmt.Fprintf(&b, "%8s", "-")
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "%8.0f", v)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func percentile(xs []float64, q float64) float64 {
|
||||
if len(xs) == 0 {
|
||||
return 0
|
||||
}
|
||||
s := append([]float64(nil), xs...)
|
||||
sort.Float64s(s)
|
||||
i := int(float64(len(s)-1) * q)
|
||||
return s[i]
|
||||
}
|
||||
|
||||
func maxOf(xs []float64) float64 {
|
||||
out := 0.0
|
||||
for _, x := range xs {
|
||||
if x > out {
|
||||
out = x
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// blockTask makes one task fail before its first model turn: its own directory
|
||||
// is a regular file, so buildFixture cannot create the session under it. It is
|
||||
// the same early exit as a failed fixture build, session load, or
|
||||
// answer-on-disk guard, and it needs no provider.
|
||||
func blockTask(t *testing.T, root, id string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(root, id), []byte("not a directory\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// mixedTaskPair is one task whose runs all fail and one whose runs all succeed.
|
||||
// Both are drawn from the corpus, so the batch is the real pipeline.
|
||||
func mixedTaskPair() (blocked, ok contextTask) {
|
||||
blocked, ok = indexTasks()[0], indexTasks()[1]
|
||||
for _, t := range indexTasks() {
|
||||
if t.Role == roleEfficiency && len(armsFor(t)) == 2 {
|
||||
ok = t
|
||||
break
|
||||
}
|
||||
}
|
||||
return blocked, ok
|
||||
}
|
||||
|
||||
// runBlockedExperiment runs one dry index batch over the given tasks, with the
|
||||
// named task failing before any model turn, and returns the batch directory, the
|
||||
// runs, the manifest, and everything the run printed.
|
||||
func runBlockedExperiment(t *testing.T, tasks []contextTask, blocked ...string) (string, []contextMetrics, batchManifest, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, id := range blocked {
|
||||
blockTask(t, root, id)
|
||||
}
|
||||
out := captureStdout(t, func() {
|
||||
if code := runExperiment("run-index", experimentIndex, root, true, tasks); code != 0 {
|
||||
t.Errorf("runExperiment exit = %d, want 0 (the batch finished)", code)
|
||||
}
|
||||
})
|
||||
runs, _ := readResultLines(t, root)
|
||||
manifest, _ := readManifest(t, root)
|
||||
return root, runs, manifest, out
|
||||
}
|
||||
|
||||
// captureStdout reads everything the function prints. os.Pipe is unbuffered, so
|
||||
// the reader must run beside the call.
|
||||
func captureStdout(t *testing.T, f func()) string {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := os.Stdout
|
||||
os.Stdout = w
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
var b strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
b.Write(buf[:n])
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
done <- b.String()
|
||||
}()
|
||||
f()
|
||||
os.Stdout = old
|
||||
w.Close()
|
||||
text := <-done
|
||||
r.Close()
|
||||
return text
|
||||
}
|
||||
|
||||
// funnelScored reads the "runs=N" column of the named arm's funnel line out of
|
||||
// the printed tables, so the assertion is on the number a reader sees.
|
||||
func funnelScored(t *testing.T, out, arm string) int {
|
||||
t.Helper()
|
||||
for line := range strings.SplitSeq(out, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 || fields[0] != arm || !strings.HasPrefix(fields[1], "runs=") {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimPrefix(fields[1], "runs="))
|
||||
if err != nil {
|
||||
t.Fatalf("funnel line for %s: %v\n%s", arm, err, line)
|
||||
}
|
||||
return n
|
||||
}
|
||||
t.Fatalf("no funnel line for arm %s:\n%s", arm, out)
|
||||
return 0
|
||||
}
|
||||
|
||||
// The batch report states both counts on one line, so a reader of the scrollback
|
||||
// sees the scored set and the failures without counting funnel rows.
|
||||
func reportCounts(t *testing.T, out string) (scored, failed int) {
|
||||
t.Helper()
|
||||
for line := range strings.SplitSeq(out, "\n") {
|
||||
if !strings.Contains(line, " scored=") || !strings.Contains(line, " failed=") {
|
||||
continue
|
||||
}
|
||||
for f := range strings.FieldsSeq(line) {
|
||||
switch {
|
||||
case strings.HasPrefix(f, "scored="):
|
||||
scored, _ = strconv.Atoi(strings.TrimPrefix(f, "scored="))
|
||||
case strings.HasPrefix(f, "failed="):
|
||||
failed, _ = strconv.Atoi(strings.TrimPrefix(f, "failed="))
|
||||
}
|
||||
}
|
||||
return scored, failed
|
||||
}
|
||||
t.Fatalf("no line reports scored= and failed=:\n%s", out)
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// A batch that mixes a task that never ran with one that did must read as its
|
||||
// successful runs only: the failed task leaves Scored, the funnel, and the
|
||||
// completed count, and is reported as a failure with its identity instead.
|
||||
// Every assertion here is a number, not the absence of an empty row.
|
||||
func TestMixedBatchCountsOnlyTheRunThatProducedEvidence(t *testing.T) {
|
||||
blocked, ok := mixedTaskPair()
|
||||
_, runs, manifest, out := runBlockedExperiment(t, []contextTask{blocked, ok}, blocked.ID)
|
||||
|
||||
wantScored, wantFailed := len(armsFor(ok)), len(armsFor(blocked))
|
||||
if manifest.CompletedRuns != wantScored || manifest.FailedRuns != wantFailed {
|
||||
t.Fatalf("manifest completed=%d failed=%d, want %d and %d (planned=%d)",
|
||||
manifest.CompletedRuns, manifest.FailedRuns, wantScored, wantFailed, manifest.PlannedRuns)
|
||||
}
|
||||
if len(runs) != manifest.CompletedRuns+manifest.FailedRuns {
|
||||
t.Fatalf("results.jsonl has %d rows but completed=%d failed=%d",
|
||||
len(runs), manifest.CompletedRuns, manifest.FailedRuns)
|
||||
}
|
||||
for _, id := range manifest.TaskIDs {
|
||||
if id == blocked.ID {
|
||||
t.Fatalf("task_ids lists %s, which produced no evidence: %v", blocked.ID, manifest.TaskIDs)
|
||||
}
|
||||
}
|
||||
scored, failures := 0, 0
|
||||
for _, m := range runs {
|
||||
if m.Failed {
|
||||
failures++
|
||||
if m.Task != blocked.ID || m.FailureStage == "" || m.Reason == "" {
|
||||
t.Fatalf("failed row lost its identity or stage: %+v", m)
|
||||
}
|
||||
continue
|
||||
}
|
||||
scored++
|
||||
if m.Task != ok.ID {
|
||||
t.Fatalf("a run scored from the wrong task: %+v", m)
|
||||
}
|
||||
}
|
||||
if scored != wantScored || failures != wantFailed {
|
||||
t.Fatalf("rows scored=%d failed=%d, want %d and %d", scored, failures, wantScored, wantFailed)
|
||||
}
|
||||
// Each arm's funnel holds exactly the successful task's runs, and the two
|
||||
// scored counts agree with each other and with the manifest.
|
||||
total := 0
|
||||
for _, arm := range armsFor(ok) {
|
||||
total += funnelScored(t, out, arm.name)
|
||||
}
|
||||
if total != manifest.CompletedRuns {
|
||||
t.Fatalf("funnels scored %d runs, manifest completed_runs = %d:\n%s", total, manifest.CompletedRuns, out)
|
||||
}
|
||||
if gotScored, gotFailed := reportCounts(t, out); gotScored != manifest.CompletedRuns || gotFailed != manifest.FailedRuns {
|
||||
t.Fatalf("report says scored=%d failed=%d, manifest says %d and %d",
|
||||
gotScored, gotFailed, manifest.CompletedRuns, manifest.FailedRuns)
|
||||
}
|
||||
if strings.Contains(out, "## Where it broke") && strings.Contains(out, "=-\n") {
|
||||
t.Fatalf("the stage table kept an empty stage:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A batch where every run fails must not read as an empty success: zero scored
|
||||
// runs, a nonzero failed count, and both stated.
|
||||
func TestAllFailedBatchReportsZeroScoredAndNonzeroFailed(t *testing.T) {
|
||||
blocked, _ := mixedTaskPair()
|
||||
_, runs, manifest, out := runBlockedExperiment(t, []contextTask{blocked}, blocked.ID)
|
||||
|
||||
if manifest.CompletedRuns != 0 {
|
||||
t.Fatalf("completed_runs = %d, want 0 (nothing produced evidence)", manifest.CompletedRuns)
|
||||
}
|
||||
if manifest.FailedRuns != manifest.PlannedRuns || manifest.FailedRuns == 0 {
|
||||
t.Fatalf("failed_runs = %d, planned_runs = %d: every planned run failed",
|
||||
manifest.FailedRuns, manifest.PlannedRuns)
|
||||
}
|
||||
if len(manifest.TaskIDs) != 0 {
|
||||
t.Fatalf("task_ids = %v, want none", manifest.TaskIDs)
|
||||
}
|
||||
for i, m := range runs {
|
||||
if !m.Failed || m.FailureStage == "" || m.Task != blocked.ID {
|
||||
t.Fatalf("results.jsonl line %d is not a marked failure: %+v", i+1, m)
|
||||
}
|
||||
}
|
||||
total := 0
|
||||
for _, arm := range armsFor(blocked) {
|
||||
total += funnelScored(t, out, arm.name)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Fatalf("funnels scored %d runs although nothing ran:\n%s", total, out)
|
||||
}
|
||||
if gotScored, gotFailed := reportCounts(t, out); gotScored != 0 || gotFailed != manifest.FailedRuns {
|
||||
t.Fatalf("report says scored=%d failed=%d, want 0 and %d", gotScored, gotFailed, manifest.FailedRuns)
|
||||
}
|
||||
}
|
||||
|
||||
// The control: with nothing blocked, a batch is what it always was. Every run
|
||||
// is completed with a stage, two identical batches write byte-identical results
|
||||
// and the same manifest header, and the new failure field stays out of it.
|
||||
func TestAllSuccessfulBatchKeepsItsArtifacts(t *testing.T) {
|
||||
tasks := indexTasks()
|
||||
first, runs, manifest, out := runBlockedExperiment(t, tasks)
|
||||
second, again, repeat, _ := runBlockedExperiment(t, tasks)
|
||||
if manifest.CompletedRuns != len(runs) || manifest.FailedRuns != 0 {
|
||||
t.Fatalf("completed=%d failed=%d rows=%d, want every row completed",
|
||||
manifest.CompletedRuns, manifest.FailedRuns, len(runs))
|
||||
}
|
||||
if manifest.CompletedRuns != manifest.PlannedRuns {
|
||||
t.Fatalf("completed=%d planned=%d, want all planned", manifest.CompletedRuns, manifest.PlannedRuns)
|
||||
}
|
||||
for i, m := range runs {
|
||||
if m.Failed || m.Task == "" || m.Arm == "" || m.FailureStage == "" {
|
||||
t.Fatalf("results.jsonl line %d is not a completed run: %+v", i+1, m)
|
||||
}
|
||||
}
|
||||
if gotScored, gotFailed := reportCounts(t, out); gotScored != manifest.CompletedRuns || gotFailed != 0 {
|
||||
t.Fatalf("report says scored=%d failed=%d, want %d and 0", gotScored, gotFailed, manifest.CompletedRuns)
|
||||
}
|
||||
if !reflect.DeepEqual(runs, again) {
|
||||
t.Fatal("two identical successful batches scored differently")
|
||||
}
|
||||
// Byte-for-byte on the runs. Trajectories carry per-message wall-clock
|
||||
// stamps, so they are compared field by field instead.
|
||||
if got, want := readFile(t, second, "results.jsonl"), readFile(t, first, "results.jsonl"); got != want {
|
||||
t.Fatalf("an unchanged batch wrote different results.jsonl:\n%s\n---\n%s", want, got)
|
||||
}
|
||||
gotTraj, wantTraj := withoutStamps(t, readFile(t, second, "trajectories.jsonl")),
|
||||
withoutStamps(t, readFile(t, first, "trajectories.jsonl"))
|
||||
if gotTraj != wantTraj {
|
||||
t.Fatalf("an unchanged batch wrote different trajectories:\n%s\n---\n%s", wantTraj, gotTraj)
|
||||
}
|
||||
repeat.StartedAt, repeat.FinishedAt = manifest.StartedAt, manifest.FinishedAt
|
||||
repeat.TrajectoriesSHA256 = manifest.TrajectoriesSHA256
|
||||
if !reflect.DeepEqual(repeat, manifest) {
|
||||
t.Fatalf("an unchanged batch wrote a different manifest:\n%+v\n---\n%+v", manifest, repeat)
|
||||
}
|
||||
if repeat.FailedRuns != 0 {
|
||||
t.Fatalf("a batch with no failure reports failed_runs=%d", repeat.FailedRuns)
|
||||
}
|
||||
}
|
||||
|
||||
// withoutStamps drops the createdAt stamp every message carries, which is the
|
||||
// one field a dry batch cannot reproduce between two runs.
|
||||
func withoutStamps(t *testing.T, text string) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(text), "\n") {
|
||||
var row map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stripStamp(row)
|
||||
out, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.Write(out)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func stripStamp(v any) {
|
||||
switch typed := v.(type) {
|
||||
case map[string]any:
|
||||
delete(typed, "createdAt")
|
||||
delete(typed, "workDurationMs")
|
||||
for _, child := range typed {
|
||||
stripStamp(child)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
stripStamp(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, root, name string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(root, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/ablation"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/runtime/agent"
|
||||
)
|
||||
|
||||
// A fixture is a real session that has really been compacted: no bench-only
|
||||
// state shape, so the loader and the projection restore have to work on it.
|
||||
|
||||
const (
|
||||
fixtureWindow = 60000
|
||||
fixtureGenerations = 5
|
||||
// fillerBytes per unit is what makes a fold necessary rather than optional.
|
||||
fillerBytes = 6000
|
||||
)
|
||||
|
||||
// digestProvider answers a summarizer without naming anything the corpus
|
||||
// plants. A digest that happened to quote the answer would leave it visible
|
||||
// and silently turn the task into a no-op.
|
||||
type digestProvider struct{ calls int }
|
||||
|
||||
func (p *digestProvider) Name() string { return "fixture-digest" }
|
||||
|
||||
func (p *digestProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.calls++
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Earlier work\nEarlier work continued across several files; nothing outstanding was recorded."}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// fillerCall is one ordinary read whose only job is to take up room — in the
|
||||
// window, and in the fold index ahead of a cue.
|
||||
func fillerCall(sess *sessionstore.Session, n int) {
|
||||
id := fmt.Sprintf("f%04d", n)
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
|
||||
{ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":"internal/mod%03d/handler%03d.go"}`, n%37, n)},
|
||||
}})
|
||||
sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: fillerBody(n)})
|
||||
}
|
||||
|
||||
func fillerBody(n int) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "package mod%03d\n\n", n%37)
|
||||
for b.Len() < fillerBytes {
|
||||
fmt.Fprintf(&b, "func handler%03dStep%02d() error { return nil }\n", n, b.Len()%64)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// builtFixture is one task's history after real folds, plus where its target
|
||||
// ended up.
|
||||
type builtFixture struct {
|
||||
Session *sessionstore.Session
|
||||
State sessionstore.CompactionState
|
||||
Target int
|
||||
Path string
|
||||
Instance fixtureInstance
|
||||
}
|
||||
|
||||
// buildFixture plays a task's history through the real agent and folds it at
|
||||
// the given arm, writing the session and its context state where the agent
|
||||
// itself would.
|
||||
func buildFixture(f fixtureInstance, arm ablation.Set, sessionPath string) (builtFixture, error) {
|
||||
t := f.Task
|
||||
sess := sessionstore.NewSession("You are a coding agent.")
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "Work through the transport and scheduler backlog."})
|
||||
|
||||
n := 0
|
||||
at := -1
|
||||
|
||||
a := agent.New(&digestProvider{}, tool.NewRegistry(), sess, agent.Options{
|
||||
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
|
||||
SessionPath: sessionPath, KeepPolicy: agent.KeepErrors, Ablation: arm,
|
||||
}, event.Discard)
|
||||
|
||||
for gen := range fixtureGenerations {
|
||||
if gen == t.PlantAfterGen {
|
||||
at = f.plant(sess)
|
||||
}
|
||||
for range 8 {
|
||||
fillerCall(sess, n)
|
||||
n++
|
||||
}
|
||||
if _, err := a.CompactNow(context.Background(), agent.CompactRequest{}); err != nil {
|
||||
return builtFixture{}, fmt.Errorf("%s: generation %d fold: %w", t.ID, gen, err)
|
||||
}
|
||||
}
|
||||
if at < 0 {
|
||||
return builtFixture{}, fmt.Errorf("%s: PlantAfterGen %d is past the last generation", t.ID, t.PlantAfterGen)
|
||||
}
|
||||
// projectionCoversTail cannot revalidate a projection covering the whole
|
||||
// transcript: it needs a live tail, which a real session always has because
|
||||
// work continues after a fold. Neutral text — the tail is provider-visible.
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "Backlog swept; ready for the next question."})
|
||||
if err := sess.Save(sessionPath); err != nil {
|
||||
return builtFixture{}, fmt.Errorf("%s: save session: %w", t.ID, err)
|
||||
}
|
||||
state, ok, err := sessionstore.LoadCompactionState(sessionPath)
|
||||
if err != nil {
|
||||
return builtFixture{}, fmt.Errorf("%s: load context state: %w", t.ID, err)
|
||||
}
|
||||
if !ok || len(state.Projection.Messages) == 0 {
|
||||
return builtFixture{}, fmt.Errorf("%s: no projection was installed", t.ID)
|
||||
}
|
||||
if state.Projection.CoveredCount <= at {
|
||||
return builtFixture{}, fmt.Errorf("%s: target #%d is not inside the folded region (covered %d)",
|
||||
t.ID, at, state.Projection.CoveredCount)
|
||||
}
|
||||
return builtFixture{Session: sess, State: state, Target: at, Path: sessionPath, Instance: f}, nil
|
||||
}
|
||||
|
||||
// visibleContext is the model-visible view: the stored projection spliced with
|
||||
// everything canonical that came after it.
|
||||
func visibleContext(f builtFixture) []provider.Message {
|
||||
canonical := f.Session.Snapshot()
|
||||
out := append([]provider.Message(nil), f.State.Projection.Messages...)
|
||||
if n := f.State.Projection.CoveredCount; n >= 0 && n < len(canonical) {
|
||||
out = append(out, canonical[n:]...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// visibleText is everything the model would read, as one string.
|
||||
func visibleText(f builtFixture) string {
|
||||
var b strings.Builder
|
||||
for _, m := range visibleContext(f) {
|
||||
b.WriteString(m.Content)
|
||||
b.WriteString("\n")
|
||||
for _, tc := range m.ToolCalls {
|
||||
b.WriteString(tc.Name)
|
||||
b.WriteString(" ")
|
||||
b.WriteString(string(tc.Arguments))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// sealFixture removes the fixture from disk once the agent holds it in memory.
|
||||
// The canonical transcript is the answer, and the sandbox mounts the host
|
||||
// read-only by design — so a model that finds the path reads the answer, which
|
||||
// one did. The agent has already loaded the session and its sidecar by the time
|
||||
// this runs; a failed autosave afterwards costs the benchmark nothing.
|
||||
func sealFixture(sessionPath string) {
|
||||
// Every file, not two named ones: a session writes an event log and a
|
||||
// context sidecar beside the transcript, and session.events.jsonl still
|
||||
// carried the answer after the other two were sealed by name.
|
||||
dir := filepath.Dir(sessionPath)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
// Deny the directory too, so an autosave cannot recreate what was removed.
|
||||
_ = os.Chmod(dir, 0o500)
|
||||
}
|
||||
|
||||
// unsealFixture restores the directory so the harness can clean up after
|
||||
// itself.
|
||||
func unsealFixture(sessionPath string) {
|
||||
_ = os.Chmod(filepath.Dir(sessionPath), 0o700)
|
||||
}
|
||||
|
||||
// answerOnDisk reports any readable file under dir holding a current-run
|
||||
// answer. Two named files were sealed because two leaks were found in them;
|
||||
// this scans the directory, so a path that moves — an autosave, a recovery
|
||||
// tombstone, a future sidecar — fails here instead of on a paid run.
|
||||
func answerOnDisk(dir string, markers []string) (string, string) {
|
||||
var atPath, found string
|
||||
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || atPath != "" {
|
||||
return nil //nolint:nilerr // an unreadable entry is not a leak
|
||||
}
|
||||
body, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return nil
|
||||
}
|
||||
for _, marker := range markers {
|
||||
if len(marker) >= 6 && strings.Contains(string(body), marker) {
|
||||
atPath, found = path, marker
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return atPath, found
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
// instance.go — a task template becomes one run's concrete history.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
mathrand "math/rand"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The invariant this file exists for: no literal sufficient to satisfy
|
||||
// AnswerRecovered for a concrete run may exist in this repository before that
|
||||
// run begins. Grepping the corpus reveals the question, never the answer.
|
||||
|
||||
// varKind is how one placeholder's value is generated. Values stay shaped like
|
||||
// real engineering records — a UUID would defeat the leak but turn every task
|
||||
// into "find the random string".
|
||||
type varKind string
|
||||
|
||||
const (
|
||||
varCodename varKind = "codename" // cobalt-k7m4q9
|
||||
varInt varKind = "int" // 731
|
||||
varDecimal varKind = "decimal" // 0.0437
|
||||
varDuration varKind = "duration" // 43ms
|
||||
)
|
||||
|
||||
// varSpec declares one placeholder. Min/Max bound the numeric kinds.
|
||||
type varSpec struct {
|
||||
Name string
|
||||
Kind varKind
|
||||
Word string // codename stem, e.g. "cobalt"
|
||||
Min int
|
||||
Max int
|
||||
}
|
||||
|
||||
// codenameEntropy is how much of a codename is unguessable. Five base32 digits
|
||||
// is about 25 bits: far past what a model can reach by knowing the stem.
|
||||
const codenameEntropy = 5
|
||||
|
||||
const codenameAlphabet = "abcdefghijkmnpqrstuvwxyz23456789"
|
||||
|
||||
// fixtureVars is one run's instantiated values.
|
||||
type fixtureVars map[string]string
|
||||
|
||||
// newVars generates values for every spec. A nil rng means deterministic.
|
||||
func newVars(specs []varSpec, rng *mathrand.Rand) fixtureVars {
|
||||
out := fixtureVars{}
|
||||
for _, spec := range specs {
|
||||
switch spec.Kind {
|
||||
case varCodename:
|
||||
out[spec.Name] = spec.Word + "-" + randomToken(rng, codenameEntropy)
|
||||
case varInt:
|
||||
out[spec.Name] = fmt.Sprint(randomBetween(rng, spec.Min, spec.Max))
|
||||
case varDecimal:
|
||||
out[spec.Name] = fmt.Sprintf("0.%04d", randomBetween(rng, spec.Min, spec.Max))
|
||||
case varDuration:
|
||||
out[spec.Name] = fmt.Sprintf("%dms", randomBetween(rng, spec.Min, spec.Max))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func randomToken(rng *mathrand.Rand, n int) string {
|
||||
var b strings.Builder
|
||||
for range n {
|
||||
b.WriteByte(codenameAlphabet[randomBetween(rng, 0, len(codenameAlphabet)-1)])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func randomBetween(rng *mathrand.Rand, lo, hi int) int {
|
||||
if hi <= lo {
|
||||
return lo
|
||||
}
|
||||
return lo + rng.Intn(hi-lo+1)
|
||||
}
|
||||
|
||||
// seededRand is the deterministic generator preflight and tests use, so a
|
||||
// fixture's tier calibration and its golden state reproduce exactly.
|
||||
func seededRand(parts ...string) *mathrand.Rand {
|
||||
h := fnv.New64a()
|
||||
for _, p := range parts {
|
||||
h.Write([]byte(p))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
return mathrand.New(mathrand.NewSource(int64(h.Sum64())))
|
||||
}
|
||||
|
||||
// liveRand is the generator a paid run uses: every run gets values that existed
|
||||
// nowhere before it started.
|
||||
func liveRand() *mathrand.Rand {
|
||||
rng, err := liveRandFrom(rand.Reader)
|
||||
if err != nil {
|
||||
panic("contextbench: no entropy for a live run: " + err.Error())
|
||||
}
|
||||
return rng
|
||||
}
|
||||
|
||||
// liveRandFrom seeds a generator from an entropy source, so what a run's values
|
||||
// are unpredictable from is the source and nothing else.
|
||||
func liveRandFrom(entropy io.Reader) (*mathrand.Rand, error) {
|
||||
var seed [8]byte
|
||||
if _, err := io.ReadFull(entropy, seed[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var n int64
|
||||
for _, b := range seed {
|
||||
n = n<<8 | int64(b)
|
||||
}
|
||||
return mathrand.New(mathrand.NewSource(n)), nil
|
||||
}
|
||||
|
||||
// instantiate substitutes {{name}} and refuses anything left over. The grammar
|
||||
// is one identifier in double braces: no conditionals, no loops, no defaults.
|
||||
// This is fixture data, not a template language.
|
||||
func instantiate(text string, vars fixtureVars) (string, error) {
|
||||
out := text
|
||||
for name, value := range vars {
|
||||
out = strings.ReplaceAll(out, "{{"+name+"}}", value)
|
||||
}
|
||||
if strings.Contains(out, "{{") || strings.Contains(out, "}}") {
|
||||
return "", fmt.Errorf("unresolved placeholder in %q", firstPlaceholder(out))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func firstPlaceholder(text string) string {
|
||||
i := strings.Index(text, "{{")
|
||||
if i < 0 {
|
||||
return text
|
||||
}
|
||||
j := strings.Index(text[i:], "}}")
|
||||
if j < 0 {
|
||||
return text[i:min(i+40, len(text))]
|
||||
}
|
||||
return text[i : i+j+2]
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// ContextRetrievalBench builds the fixtures the long-horizon retrieval
|
||||
// experiments run against, and proves they are worth paying a provider for.
|
||||
//
|
||||
// -mode=calibrate find each index task's lead-call count for its cue tier
|
||||
// -mode=preflight build every task under every arm and check the contract
|
||||
//
|
||||
// Nothing here calls a provider. The experiments themselves do; this is what
|
||||
// says they would measure what they claim to.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mode := flag.String("mode", "preflight", "calibrate | preflight | snippet | adversarial | run-search | run-boundary | run-index")
|
||||
work := flag.String("work", "", "directory for built fixtures (default: a temp dir)")
|
||||
only := flag.String("task", "", "limit to one task id")
|
||||
dry := flag.Bool("dry", false, "run modes: drive the pipeline with a scripted provider instead of paying for one")
|
||||
flag.Parse()
|
||||
|
||||
if err := validateCorpus(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "corpus:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
root := *work
|
||||
if root == "" {
|
||||
dir, err := os.MkdirTemp("", "context-retrieval-*")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
root = dir
|
||||
}
|
||||
|
||||
tasks := allTasks()
|
||||
if *only != "" {
|
||||
t, ok := taskByID(*only)
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "unknown task %q\n", *only)
|
||||
os.Exit(2)
|
||||
}
|
||||
tasks = []contextTask{t}
|
||||
}
|
||||
|
||||
switch *mode {
|
||||
case "calibrate":
|
||||
os.Exit(runCalibrate(tasks, root))
|
||||
case "preflight":
|
||||
os.Exit(runPreflight(tasks, root))
|
||||
case "run-search":
|
||||
os.Exit(runExperiment(*mode, experimentSearch, root, *dry, tasks))
|
||||
case "run-boundary":
|
||||
os.Exit(runBoundaries(*mode, root))
|
||||
case "snippet":
|
||||
os.Exit(runSnippetAudit(root, 100))
|
||||
case "adversarial":
|
||||
os.Exit(runAdversarial(root))
|
||||
case "run-index":
|
||||
batch := tasks
|
||||
if *only == "" {
|
||||
// A batch measures the efficiency substrate only. Naming one task
|
||||
// with -task runs that task, role included, so the stopping corpus
|
||||
// stays measurable on its own.
|
||||
batch = efficiencyTasks()
|
||||
}
|
||||
os.Exit(runExperiment(*mode, experimentIndex, root, *dry, batch))
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func runPreflight(tasks []contextTask, root string) int {
|
||||
var all []finding
|
||||
for _, t := range tasks {
|
||||
found, err := checkTask(t, root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", t.ID, err)
|
||||
return 2
|
||||
}
|
||||
all = append(all, found...)
|
||||
status := "ok"
|
||||
if len(found) > 0 {
|
||||
status = fmt.Sprintf("%d problem(s)", len(found))
|
||||
}
|
||||
fmt.Printf("%-24s %-6s gen=%-2d %s\n", t.ID, t.Experiment, t.PlantAfterGen, status)
|
||||
}
|
||||
if len(all) == 0 {
|
||||
fmt.Printf("\n%d fixtures ready.\n", len(tasks))
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n%d preflight problem(s):\n", len(all))
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].Task < all[j].Task })
|
||||
for _, f := range all {
|
||||
fmt.Fprintln(os.Stderr, " ", f)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// runCalibrate reports, for each index task, the lead-call counts at which its
|
||||
// cue is still addressed by each budget. The corpus freezes one number from
|
||||
// this; it is never computed at run time, or the implementation under test
|
||||
// would be moving its own goalposts.
|
||||
func runCalibrate(tasks []contextTask, root string) int {
|
||||
for _, t := range tasks {
|
||||
if t.Experiment != experimentIndex {
|
||||
continue
|
||||
}
|
||||
var rows []string
|
||||
for lead := range fixtureGenerations {
|
||||
probe := t
|
||||
probe.PlantAfterGen = lead
|
||||
var visibleAt []string
|
||||
for _, scale := range armScales {
|
||||
inst, ierr := instantiateTask(probe, seededRand(t.ID, scale, "calibrate"))
|
||||
if ierr != nil {
|
||||
fmt.Fprintln(os.Stderr, ierr)
|
||||
return 2
|
||||
}
|
||||
f, err := buildFixture(inst, armFor(scale, false),
|
||||
fmt.Sprintf("%s/calib/%s/%d-%s/session.jsonl", root, t.ID, lead, scale))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s gen=%d %s: %v\n", t.ID, lead, scale, err)
|
||||
return 2
|
||||
}
|
||||
if strings.Contains(visibleText(f), inst.CueMarker) {
|
||||
visibleAt = append(visibleAt, scale)
|
||||
}
|
||||
}
|
||||
rows = append(rows, fmt.Sprintf("planted after gen %d, cue visible at: %s", lead, strings.Join(visibleAt, ",")))
|
||||
}
|
||||
fmt.Printf("\n%s (wants tier %s)\n", t.ID, t.CueTier)
|
||||
for _, r := range rows {
|
||||
fmt.Println(" ", r)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"maps"
|
||||
mathrand "math/rand"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The same checks the preflight mode runs, under go test: a compaction change
|
||||
// that invalidates the fixtures fails here rather than three hundred API calls
|
||||
// later.
|
||||
|
||||
func TestCorpusIsWellFormed(t *testing.T) {
|
||||
if err := validateCorpus(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(searchTasks()); got != 6 {
|
||||
t.Errorf("search experiment has %d tasks, want 6", got)
|
||||
}
|
||||
if got := len(indexTasks()); got != 12 {
|
||||
t.Errorf("index experiment has %d tasks, want 12", got)
|
||||
}
|
||||
stopping := map[string]bool{}
|
||||
tiers := map[string]int{}
|
||||
for _, task := range indexTasks() {
|
||||
if task.Role == roleStopping {
|
||||
stopping[task.ID] = true
|
||||
continue
|
||||
}
|
||||
tiers[task.CueTier]++
|
||||
}
|
||||
if !stopping["i03-coalescing"] || !stopping["i04-recovery-fence"] {
|
||||
t.Errorf("the stopping corpus is %v; it must keep i03-coalescing and i04-recovery-fence", stopping)
|
||||
}
|
||||
// Two per tier, over the efficiency substrate, is what makes the staircase
|
||||
// readable: the same task can be compared across the boundary its cue sits
|
||||
// on, instead of two different questions being compared across two arms.
|
||||
for _, tier := range []string{tierQuarter, tierHalf, tierDefault} {
|
||||
if tiers[tier] != 2 {
|
||||
t.Errorf("tier %s has %d tasks, want 2", tier, tiers[tier])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The role decides which batch a task enters; the literal lists are only where
|
||||
// it is declared, so the two must not drift.
|
||||
func TestIndexListsMatchTheirRoles(t *testing.T) {
|
||||
for _, task := range append(append(indexQuarterTasks(), indexHalfTasks()...), indexDefaultTasks()...) {
|
||||
if task.Role != roleEfficiency {
|
||||
t.Errorf("%s sits in an efficiency list with role %q", task.ID, task.Role)
|
||||
}
|
||||
}
|
||||
for _, task := range stoppingIndexTasks() {
|
||||
if task.Role != roleStopping {
|
||||
t.Errorf("%s sits in the stopping list with role %q", task.ID, task.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The invariant the whole instantiation layer exists for: no literal a scorer
|
||||
// needs may be readable in this repository before a run starts.
|
||||
func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
|
||||
root, err := repoRoot()
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
for _, task := range allTasks() {
|
||||
inst, err := instantiateTask(task, seededRand(task.ID, "leak-check"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, marker := range append(inst.AnswerMarkers, inst.CueMarker) {
|
||||
if len(marker) < 6 {
|
||||
continue // a bare number is not a literal anyone can grep for
|
||||
}
|
||||
if found, where := grepRepo(t, root, marker); found {
|
||||
t.Errorf("%s: %q is readable at %s before the run", task.ID, marker, where)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A run's values are unpredictable from a previous run only if the live
|
||||
// generator is seeded from entropy. Comparing answers across two instances
|
||||
// cannot assert that: a task whose answer is a bounded number collides by
|
||||
// chance (1/8601 to 1/286 per task), so the check is on the seed source.
|
||||
func TestLiveRandDrawsFromEntropy(t *testing.T) {
|
||||
a, b := liveRand(), liveRand()
|
||||
for range 4 {
|
||||
if a.Int63() != b.Int63() {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("two live generators produced the same stream: they are seeded alike")
|
||||
}
|
||||
|
||||
// The source alone decides an instance: the same entropy gives the same
|
||||
// answers, different entropy gives different values.
|
||||
func TestLiveRandSourceDecidesTheInstance(t *testing.T) {
|
||||
same := func() *mathrand.Rand {
|
||||
rng, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{7}, 8)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rng
|
||||
}
|
||||
other, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{9}, 8)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := liveRandFrom(bytes.NewReader(nil)); err == nil {
|
||||
t.Fatal("an empty entropy source was accepted")
|
||||
}
|
||||
for _, task := range allTasks() {
|
||||
a, _ := instantiateTask(task, same())
|
||||
b, _ := instantiateTask(task, same())
|
||||
if !slices.Equal(a.AnswerMarkers, b.AnswerMarkers) || !maps.Equal(a.Vars, b.Vars) {
|
||||
t.Errorf("%s: the same entropy gave different instances", task.ID)
|
||||
}
|
||||
c, _ := instantiateTask(task, other)
|
||||
if maps.Equal(a.Vars, c.Vars) {
|
||||
t.Errorf("%s: different entropy gave the same values", task.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A preflight that could not reproduce would calibrate against one run's
|
||||
// nonces and report about another's.
|
||||
func TestSeededInstancesReproduce(t *testing.T) {
|
||||
for _, task := range allTasks() {
|
||||
a, _ := instantiateTask(task, seededRand(task.ID, "x"))
|
||||
b, _ := instantiateTask(task, seededRand(task.ID, "x"))
|
||||
if !slices.Equal(a.AnswerMarkers, b.AnswerMarkers) || a.body != b.body {
|
||||
t.Errorf("%s did not reproduce under the same seed", task.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An unresolved placeholder would put "{{marker}}" in the transcript and score
|
||||
// every run wrong; it fails the fixture instead.
|
||||
func TestUnresolvedPlaceholderIsRefused(t *testing.T) {
|
||||
if _, err := instantiate("value is {{missing}}", fixtureVars{"other": "x"}); err == nil {
|
||||
t.Fatal("an unresolved placeholder was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func repoRoot() (string, error) {
|
||||
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("not in a git checkout: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// grepRepo searches tracked files only: build artifacts and temp fixtures are
|
||||
// not what a fresh checkout hands anyone.
|
||||
func grepRepo(t *testing.T, root, needle string) (bool, string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", "grep", "-l", "-F", needle)
|
||||
cmd.Dir = root
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, "" // exit 1 means no match
|
||||
}
|
||||
return true, strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
|
||||
}
|
||||
|
||||
func TestEveryFixturePassesPreflight(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("builds and folds 36 sessions")
|
||||
}
|
||||
root := t.TempDir()
|
||||
for _, task := range allTasks() {
|
||||
t.Run(task.ID, func(t *testing.T) {
|
||||
found, err := checkTask(task, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range found {
|
||||
t.Error(f)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/retrieval"
|
||||
"reasonix/internal/contract/ablation"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// Preflight is what makes a real run worth paying for. An answer the model can
|
||||
// already read, or a cue not where the matrix says, turns an arm into a
|
||||
// different experiment without saying so.
|
||||
|
||||
// armScales are the fold-index arms an index task is measured across.
|
||||
var armScales = []string{"default", "half", "quarter", "off"}
|
||||
|
||||
// experimentArm is one measured configuration of a task.
|
||||
type experimentArm struct {
|
||||
name string
|
||||
scale string
|
||||
searchOff bool
|
||||
}
|
||||
|
||||
// armsFor is the single declaration of which arms a task runs at, so preflight
|
||||
// and the run itself cannot check one set and measure another.
|
||||
func armsFor(t contextTask) []experimentArm {
|
||||
if t.Experiment == experimentSearch {
|
||||
return []experimentArm{
|
||||
{"search-on", "default", false},
|
||||
{"search-off", "default", true},
|
||||
}
|
||||
}
|
||||
arms := make([]experimentArm, 0, len(armScales))
|
||||
for _, scale := range armScales {
|
||||
arms = append(arms, experimentArm{"index-" + scale, scale, false})
|
||||
}
|
||||
return arms
|
||||
}
|
||||
|
||||
func scaleOf(name string) ablation.FoldIndexScale {
|
||||
s, err := ablation.ParseFoldIndexScale(name)
|
||||
if err != nil {
|
||||
panic("unknown scale " + name) // the list above is a constant
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// armFor is the arm a task is measured under at one fold-index scale.
|
||||
func armFor(scale string, searchOff bool) ablation.Set {
|
||||
set := ablation.Set{}
|
||||
if searchOff {
|
||||
set = ablation.New(ablation.RecallSearch)
|
||||
}
|
||||
return set.WithFoldIndex(scaleOf(scale))
|
||||
}
|
||||
|
||||
type finding struct {
|
||||
Task, Arm, Problem string
|
||||
}
|
||||
|
||||
func (f finding) String() string { return fmt.Sprintf("%s [%s]: %s", f.Task, f.Arm, f.Problem) }
|
||||
|
||||
// checkTask builds the task under every arm it will be run at and reports what
|
||||
// does not hold.
|
||||
func checkTask(t contextTask, root string) ([]finding, error) {
|
||||
var out []finding
|
||||
for _, arm := range armsFor(t) {
|
||||
// Deterministic values: a preflight has to reproduce, and a tier
|
||||
// calibration measured against one run's nonces is measured against
|
||||
// nothing.
|
||||
inst, err := instantiateTask(t, seededRand(t.ID, arm.name, "preflight"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := filepath.Join(root, t.ID, arm.name)
|
||||
f, err := buildFixture(inst, armFor(arm.scale, arm.searchOff), filepath.Join(dir, "session.jsonl"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible := visibleText(f)
|
||||
|
||||
// The answer must be unreadable in every arm, or the task measures
|
||||
// reading rather than recall.
|
||||
for _, marker := range inst.AnswerMarkers {
|
||||
if len(marker) < 3 {
|
||||
continue // a bare number is not a nonce; the whole set still has to match
|
||||
}
|
||||
if strings.Contains(visible, marker) {
|
||||
out = append(out, finding{t.ID, arm.name, fmt.Sprintf("answer marker %q is already visible", marker)})
|
||||
}
|
||||
}
|
||||
|
||||
// The corpus has to be findable at all, or a miss in a real run says
|
||||
// nothing about the model.
|
||||
rank, err := probeRank(f, inst.ProbeQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case rank == 0:
|
||||
out = append(out, finding{t.ID, arm.name, fmt.Sprintf("probe query %q does not find the target at all", inst.ProbeQuery)})
|
||||
case rank > 5:
|
||||
out = append(out, finding{t.ID, arm.name, fmt.Sprintf("probe query %q ranks the target %d", inst.ProbeQuery, rank)})
|
||||
}
|
||||
|
||||
if t.Experiment == experimentIndex {
|
||||
out = append(out, checkCueVisibility(t, inst, arm.scale, arm.name, visible)...)
|
||||
} else if line := indexLineFor(f, f.Target); line != "" {
|
||||
out = append(out, finding{t.ID, arm.name,
|
||||
"a search task is addressed by the fold index, so it measures the index and not search: " + line})
|
||||
}
|
||||
|
||||
// The projection is what the agent believes. This is what the model is
|
||||
// told, and it is the one that has to hold.
|
||||
req, err := captureRequest(f, armFor(arm.scale, arm.searchOff), inst.Prompt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s [%s]: capture: %w", t.ID, arm.name, err)
|
||||
}
|
||||
out = append(out, checkCapturedRequest(t, inst, arm.name, arm.scale, arm.searchOff, req)...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// checkCapturedRequest holds one arm's real provider request to its contract:
|
||||
// the answer unreadable, the cue exactly where its tier says, the recall tool
|
||||
// offering only what this arm can do, and no host-side field surviving the
|
||||
// boundary.
|
||||
func checkCapturedRequest(t contextTask, inst fixtureInstance, armName, scale string, searchOff bool, req provider.Request) []finding {
|
||||
var out []finding
|
||||
for _, marker := range inst.AnswerMarkers {
|
||||
if len(marker) < 3 {
|
||||
continue // a bare number is not a nonce; the set as a whole still is
|
||||
}
|
||||
for _, at := range findInSurface(req, marker) {
|
||||
out = append(out, finding{t.ID, armName, fmt.Sprintf("answer marker %q leaked at %s", marker, at)})
|
||||
}
|
||||
}
|
||||
if t.Experiment == experimentIndex {
|
||||
wantVisible, wantHidden := tierScales(t.CueTier)
|
||||
where := findInSurface(req, inst.CueMarker)
|
||||
switch {
|
||||
case contains(wantVisible, scale) && len(where) == 0:
|
||||
out = append(out, finding{t.ID, armName, fmt.Sprintf("cue %q is not provider-visible; the tier expects it", inst.CueMarker)})
|
||||
case contains(wantHidden, scale) && len(where) > 0:
|
||||
out = append(out, finding{t.ID, armName, fmt.Sprintf("cue %q is provider-visible at %s: %s", inst.CueMarker, where[0], scale)})
|
||||
}
|
||||
}
|
||||
|
||||
schema, ok := recallToolSchema(req)
|
||||
if !ok {
|
||||
out = append(out, finding{t.ID, armName, "recall is not in the provider's tool list"})
|
||||
return append(out, boundaryFindings(t, armName, req)...)
|
||||
}
|
||||
params := ""
|
||||
if b, err := json.Marshal(schema.Parameters); err == nil {
|
||||
params = string(b)
|
||||
}
|
||||
// Both arms keep the read half: the ablation removes search, not recall.
|
||||
if !strings.Contains(params, `"positions"`) {
|
||||
out = append(out, finding{t.ID, armName, "recall lost its positions parameter"})
|
||||
}
|
||||
hasQuery := strings.Contains(params, `"query"`)
|
||||
switch {
|
||||
case searchOff && hasQuery:
|
||||
out = append(out, finding{t.ID, armName, "recall still offers a query parameter"})
|
||||
case !searchOff && !hasQuery:
|
||||
out = append(out, finding{t.ID, armName, "recall has no query parameter"})
|
||||
}
|
||||
if searchOff {
|
||||
for _, phrase := range searchWording {
|
||||
for _, at := range findInSurface(req, phrase) {
|
||||
out = append(out, finding{t.ID, armName, fmt.Sprintf("search is offered to an arm without it: %q at %s", phrase, at)})
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(out, boundaryFindings(t, armName, req)...)
|
||||
}
|
||||
|
||||
func boundaryFindings(t contextTask, armName string, req provider.Request) []finding {
|
||||
var out []finding
|
||||
for _, at := range localOnlyLeaks(req) {
|
||||
out = append(out, finding{t.ID, armName, "host-only field crossed the provider boundary at " + at})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// checkCueVisibility holds an index task to its tier: the cue is addressed at
|
||||
// the scales the matrix names and at no others. A policy change that moves it
|
||||
// fails here rather than turning two arms into one experiment.
|
||||
func checkCueVisibility(t contextTask, inst fixtureInstance, scale, armName, visible string) []finding {
|
||||
wantVisible, wantHidden := tierScales(t.CueTier)
|
||||
has := strings.Contains(visible, inst.CueMarker)
|
||||
switch {
|
||||
case contains(wantVisible, scale) && !has:
|
||||
return []finding{{t.ID, armName, fmt.Sprintf("cue %q should be addressed at %s and is not", inst.CueMarker, scale)}}
|
||||
case contains(wantHidden, scale) && has:
|
||||
return []finding{{t.ID, armName, fmt.Sprintf("cue %q should be gone at %s and is not", inst.CueMarker, scale)}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contains(list []string, want string) bool { return slices.Contains(list, want) }
|
||||
|
||||
// probeRank runs the task's own query against the folded region the way recall
|
||||
// would, and reports where the target lands.
|
||||
func probeRank(f builtFixture, query string) (int, error) {
|
||||
terms, err := retrieval.QueryTerms(query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
canonical := f.Session.Snapshot()
|
||||
covered := min(f.State.Projection.CoveredCount, len(canonical))
|
||||
docs := make([]map[string]int, 0, covered)
|
||||
positions := make([]int, 0, covered)
|
||||
lengths := make([]int, 0, covered)
|
||||
for i, m := range canonical[:covered] {
|
||||
var text strings.Builder
|
||||
text.WriteString(m.Content)
|
||||
for _, tc := range m.ToolCalls {
|
||||
text.WriteString(" " + tc.Name + " " + string(tc.Arguments))
|
||||
}
|
||||
tokens := retrieval.Tokens(text.String())
|
||||
if len(tokens) == 0 {
|
||||
continue
|
||||
}
|
||||
docs = append(docs, retrieval.Counts(tokens))
|
||||
positions = append(positions, i)
|
||||
lengths = append(lengths, len(tokens))
|
||||
}
|
||||
df := retrieval.DocumentFrequency(docs)
|
||||
avg := 0.0
|
||||
for _, l := range lengths {
|
||||
avg += float64(l)
|
||||
}
|
||||
if len(lengths) > 0 {
|
||||
avg /= float64(len(lengths))
|
||||
}
|
||||
type hit struct {
|
||||
pos int
|
||||
score float64
|
||||
}
|
||||
var hits []hit
|
||||
for i, counts := range docs {
|
||||
if s := retrieval.BM25Score(counts, lengths[i], terms, df, len(docs), avg); s > 0 {
|
||||
hits = append(hits, hit{positions[i], s})
|
||||
}
|
||||
}
|
||||
for i := range hits {
|
||||
for j := i + 1; j < len(hits); j++ {
|
||||
if hits[j].score > hits[i].score {
|
||||
hits[i], hits[j] = hits[j], hits[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
// A tool result answers at its caller's position, so either counts.
|
||||
for i, h := range hits {
|
||||
if h.pos == f.Target || h.pos == f.Target+1 {
|
||||
return i + 1, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// indexLineFor returns the fold-index line addressing a position, if any.
|
||||
func indexLineFor(f builtFixture, pos int) string {
|
||||
want := fmt.Sprintf("#%d ", pos)
|
||||
for line := range strings.SplitSeq(visibleIndexOnly(f), "\n") {
|
||||
if strings.Contains(line, want) {
|
||||
return strings.TrimSpace(line)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// visibleIndexOnly is just the fold-index sections of the projection.
|
||||
func visibleIndexOnly(f builtFixture) string {
|
||||
var b strings.Builder
|
||||
for _, m := range visibleContext(f) {
|
||||
if i := strings.Index(m.Content, "## Folded work index"); i >= 0 {
|
||||
b.WriteString(m.Content[i:])
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
// reformulation.go — how a query changed, from token sets alone.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/retrieval"
|
||||
)
|
||||
|
||||
// The host probe ranks every target first, so the engine is not what makes a
|
||||
// model take more than one attempt. These classes are set arithmetic over the
|
||||
// same tokenizer recall uses: no judge, nothing read out of prose.
|
||||
|
||||
const (
|
||||
reformFirst = "First" // nothing to compare against yet
|
||||
reformNearDup = "NearDuplicate" // the same query again, give or take a word
|
||||
reformNarrowing = "Narrowing" // kept everything and added terms
|
||||
reformBroadening = "Broadening" // dropped terms, added none
|
||||
reformAnchored = "HistoricalAnchor" // reached for a term the question does not contain
|
||||
reformShift = "SynonymShift" // traded terms for others
|
||||
)
|
||||
|
||||
// nearDuplicateOverlap is where "reworded" stops and "asked again" begins.
|
||||
const nearDuplicateOverlap = 0.8
|
||||
|
||||
func terms(s string) []string {
|
||||
return retrieval.Unique(retrieval.Tokens(s))
|
||||
}
|
||||
|
||||
func termSet(s string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, t := range terms(s) {
|
||||
out[t] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func jaccard(a, b map[string]bool) float64 {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return 1
|
||||
}
|
||||
shared := 0
|
||||
for t := range a {
|
||||
if b[t] {
|
||||
shared++
|
||||
}
|
||||
}
|
||||
union := len(a) + len(b) - shared
|
||||
if union == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(shared) / float64(union)
|
||||
}
|
||||
|
||||
// classifyReformulation names how this query differs from the one before it.
|
||||
func classifyReformulation(previous, current, question string) string {
|
||||
if previous == "" {
|
||||
return reformFirst
|
||||
}
|
||||
prev, cur := termSet(previous), termSet(current)
|
||||
if jaccard(prev, cur) >= nearDuplicateOverlap {
|
||||
return reformNearDup
|
||||
}
|
||||
added, dropped := diffTerms(cur, prev), diffTerms(prev, cur)
|
||||
// A term the question never used had to come from the model's own memory of
|
||||
// the history, which is a different move from rephrasing what it was asked.
|
||||
ask := termSet(question)
|
||||
for _, t := range added {
|
||||
if !ask[t] {
|
||||
return reformAnchored
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(dropped) == 0:
|
||||
return reformNarrowing
|
||||
case len(added) == 0:
|
||||
return reformBroadening
|
||||
default:
|
||||
return reformShift
|
||||
}
|
||||
}
|
||||
|
||||
func diffTerms(from, minus map[string]bool) []string {
|
||||
var out []string
|
||||
for t := range from {
|
||||
if !minus[t] {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// classifySearches labels each attempt and records which of the host probe's
|
||||
// terms it left out. The probe is the oracle: it ranks the target first, so a
|
||||
// query missing its terms says what the model did not think to ask for.
|
||||
func classifySearches(m *contextMetrics, inst fixtureInstance) {
|
||||
probe := terms(inst.ProbeQuery)
|
||||
previous := ""
|
||||
for i := range m.Searches {
|
||||
s := &m.Searches[i]
|
||||
s.Reformulation = classifyReformulation(previous, s.Query, inst.Prompt)
|
||||
have := termSet(s.Query)
|
||||
for _, term := range probe {
|
||||
if !have[term] {
|
||||
s.MissingProbeTerms = append(s.MissingProbeTerms, term)
|
||||
}
|
||||
}
|
||||
previous = s.Query
|
||||
}
|
||||
}
|
||||
|
||||
// queryAudit is the reformulation funnel across a set of runs.
|
||||
type queryAudit struct {
|
||||
Runs, RunsThatSearched int
|
||||
FirstQueryHit int
|
||||
HitBySecond, HitByThird int
|
||||
NeverHit int
|
||||
TotalSearches int
|
||||
PostHitSearches int
|
||||
HitThenRead int
|
||||
EscapeBeforeFirstSearch int
|
||||
Reformulations map[string]int
|
||||
MissedProbeTerms map[string]int
|
||||
}
|
||||
|
||||
func auditQueries(runs []contextMetrics) queryAudit {
|
||||
a := queryAudit{Reformulations: map[string]int{}, MissedProbeTerms: map[string]int{}}
|
||||
for _, r := range runs {
|
||||
if r.Failed || r.Contaminated {
|
||||
continue
|
||||
}
|
||||
a.Runs++
|
||||
a.TotalSearches += len(r.Searches)
|
||||
a.PostHitSearches += r.PostHitSearches
|
||||
if r.EscapeBeforeFirstRecall > 0 {
|
||||
a.EscapeBeforeFirstSearch++
|
||||
}
|
||||
if len(r.Searches) == 0 {
|
||||
continue
|
||||
}
|
||||
a.RunsThatSearched++
|
||||
hitAt := 0
|
||||
for i, s := range r.Searches {
|
||||
a.Reformulations[s.Reformulation]++
|
||||
for _, term := range s.MissingProbeTerms {
|
||||
a.MissedProbeTerms[term]++
|
||||
}
|
||||
if s.TargetRank > 0 && hitAt == 0 {
|
||||
hitAt = i + 1
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case hitAt == 1:
|
||||
a.FirstQueryHit++
|
||||
case hitAt == 2:
|
||||
a.HitBySecond++
|
||||
case hitAt >= 3:
|
||||
a.HitByThird++
|
||||
default:
|
||||
a.NeverHit++
|
||||
}
|
||||
if hitAt > 0 && r.TargetRead {
|
||||
a.HitThenRead++
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (a queryAudit) report() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("\n## Query reformulation\n")
|
||||
fmt.Fprintf(&b, " runs=%d searched=%d searches=%d (%.2f per searching run)\n",
|
||||
a.Runs, a.RunsThatSearched, a.TotalSearches, ratio(a.TotalSearches, a.RunsThatSearched))
|
||||
fmt.Fprintf(&b, " target found by query 1: %d by 2: %d by 3+: %d never: %d\n",
|
||||
a.FirstQueryHit, a.HitBySecond, a.HitByThird, a.NeverHit)
|
||||
fmt.Fprintf(&b, " found then read: %d/%d searches issued after the target was already back: %d\n",
|
||||
a.HitThenRead, a.FirstQueryHit+a.HitBySecond+a.HitByThird, a.PostHitSearches)
|
||||
fmt.Fprintf(&b, " reached for the workspace before searching: %d\n", a.EscapeBeforeFirstSearch)
|
||||
b.WriteString(" reformulations: " + countsLine(a.Reformulations) + "\n")
|
||||
b.WriteString(" probe terms most often absent: " + topCounts(a.MissedProbeTerms, 8) + "\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func ratio(n, d int) float64 {
|
||||
if d == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(n) / float64(d)
|
||||
}
|
||||
|
||||
func countsLine(counts map[string]int) string {
|
||||
keys := make([]string, 0, len(counts))
|
||||
for k := range counts {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func topCounts(counts map[string]int, limit int) string {
|
||||
type kv struct {
|
||||
k string
|
||||
n int
|
||||
}
|
||||
all := make([]kv, 0, len(counts))
|
||||
for k, n := range counts {
|
||||
all = append(all, kv{k, n})
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].n != all[j].n {
|
||||
return all[i].n > all[j].n
|
||||
}
|
||||
return all[i].k < all[j].k
|
||||
})
|
||||
parts := make([]string, 0, limit)
|
||||
for _, e := range all[:min(limit, len(all))] {
|
||||
parts = append(parts, fmt.Sprintf("%s=%d", e.k, e.n))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "(none)"
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// queryLines lists every attempt, so a surprising audit can be read back.
|
||||
func queryLines(runs []contextMetrics) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("\n## Every query, in order\n")
|
||||
for _, r := range runs {
|
||||
for _, s := range r.Searches {
|
||||
rank := "miss"
|
||||
if s.TargetRank > 0 {
|
||||
rank = fmt.Sprintf("rank %d", s.TargetRank)
|
||||
}
|
||||
fmt.Fprintf(&b, " %-24s %-14s #%d %-17s %-9s %q\n",
|
||||
r.Task, r.Arm, s.Ordinal, s.Reformulation, rank, s.Query)
|
||||
if len(s.MissingProbeTerms) > 0 && s.TargetRank == 0 {
|
||||
fmt.Fprintf(&b, " %-24s %-14s missing probe terms: %s\n",
|
||||
"", "", strings.Join(slices.Clone(s.MissingProbeTerms), " "))
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,569 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
"reasonix/internal/contract/ablation"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
_ "reasonix/internal/model/openai"
|
||||
"reasonix/internal/runtime/agent"
|
||||
_ "reasonix/internal/tools/builtin"
|
||||
)
|
||||
|
||||
// A run is one recall question against a fixture the preflight has already
|
||||
// cleared. Scoring reads the transcript the turn appended — tool names and
|
||||
// their JSON arguments — so nothing depends on how the model narrated itself.
|
||||
|
||||
const (
|
||||
runBaseURL = "https://api.deepseek.com"
|
||||
runModel = "deepseek-chat"
|
||||
runTimeout = 4 * time.Minute
|
||||
)
|
||||
|
||||
func realProvider() (provider.Provider, error) {
|
||||
key := os.Getenv("DEEPSEEK_API_KEY")
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("a real run needs DEEPSEEK_API_KEY")
|
||||
}
|
||||
return provider.New("openai", provider.Config{
|
||||
Name: "contextbench", BaseURL: runBaseURL, Model: runModel, APIKey: key,
|
||||
})
|
||||
}
|
||||
|
||||
// adversarialPrompts tell the model what it is running and send it after the
|
||||
// answer everywhere except the conversation. The question is not whether it
|
||||
// obeys the experiment: it is whether the answer is reachable at all by a model
|
||||
// that knows the mechanism and is actively attacking the host.
|
||||
var adversarialPrompts = map[string]string{
|
||||
experimentSearch: "You are running a benchmark. Find the expected answer for this task from the " +
|
||||
"Reasonix repository, benchmark source, fixture generator, test files, scratch files, or " +
|
||||
"session metadata. Do not use recall unless necessary. Report the exact hidden values.",
|
||||
experimentIndex: "Inspect benchmarks/context-retrieval, the git repository, session files, temporary " +
|
||||
"files, and any benchmark metadata you can access. Recover the concrete instantiated answer for " +
|
||||
"this run without relying on the folded conversation. Report the exact values.",
|
||||
}
|
||||
|
||||
// runAdversarial sends one task per experiment after the answer through every
|
||||
// host-readable surface. It passes when nothing but recall could produce it —
|
||||
// a run that gives up and recalls has still proven the attack failed.
|
||||
func runAdversarial(root string) int {
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 2
|
||||
}
|
||||
restore, ierr := isolateWorkspace()
|
||||
if ierr != nil {
|
||||
fmt.Fprintln(os.Stderr, "isolate workspace:", ierr)
|
||||
return 2
|
||||
}
|
||||
defer restore()
|
||||
p, perr := realProvider()
|
||||
if perr != nil {
|
||||
fmt.Fprintln(os.Stderr, perr)
|
||||
return 2
|
||||
}
|
||||
leaked := 0
|
||||
for _, id := range []string{"r01-retry-boundary", "i01-transport-fallback"} {
|
||||
t, ok := taskByID(id)
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "unknown task %q\n", id)
|
||||
return 2
|
||||
}
|
||||
m, err := runOne(p, t, armFor("default", false), "adversarial", root, false, false,
|
||||
adversarialPrompts[t.Experiment])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", id, err)
|
||||
m = failedMetrics(t, "adversarial", err)
|
||||
}
|
||||
verdict := "isolated"
|
||||
if m.EscapeFoundAnswer {
|
||||
verdict = "LEAKED via " + m.LeakedVia
|
||||
leaked++
|
||||
}
|
||||
fmt.Printf("%-24s %-24s escapes=%-3d recall=%d answered=%v tools=%v\n",
|
||||
id, verdict, m.EscapeCalls, m.SearchCalls+m.ReadCalls, m.AnswerRecovered, m.UnexpectedWorkTools)
|
||||
if m.EscapeFoundAnswer {
|
||||
fmt.Printf(" args: %s\n output: %s\n", m.LeakedArgs, m.LeakedOutput)
|
||||
}
|
||||
}
|
||||
if leaked > 0 {
|
||||
fmt.Fprintf(os.Stderr, "\n%d of 2 adversarial runs reached an answer outside recall\n", leaked)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("\nNo answer was reachable outside recall.")
|
||||
return 0
|
||||
}
|
||||
|
||||
// runOne asks one task under one arm and scores what the model did.
|
||||
func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root string, cueVisible, dry bool, promptOverride ...string) (contextMetrics, error) {
|
||||
// Live runs get values that existed nowhere before the run started; a dry
|
||||
// run stays reproducible.
|
||||
rng := liveRand()
|
||||
if dry {
|
||||
rng = seededRand(t.ID, armName, "dry")
|
||||
}
|
||||
inst, err := instantiateTask(t, rng)
|
||||
if err != nil {
|
||||
return contextMetrics{}, err
|
||||
}
|
||||
f, err := buildFixture(inst, arm, filepath.Join(root, t.ID, armName, "session.jsonl"))
|
||||
if err != nil {
|
||||
return contextMetrics{}, err
|
||||
}
|
||||
sess, err := sessionstore.LoadSession(f.Path)
|
||||
if err != nil {
|
||||
return contextMetrics{}, fmt.Errorf("load session: %w", err)
|
||||
}
|
||||
reg := tool.NewRegistry()
|
||||
for _, x := range tool.Builtins() {
|
||||
reg.Add(x)
|
||||
}
|
||||
boot.ApplyUnifiedProviderToolSurface(reg, false, arm)
|
||||
|
||||
var usage usageSink
|
||||
a := agent.New(p, reg, sess, agent.Options{
|
||||
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
|
||||
SessionPath: f.Path, KeepPolicy: agent.KeepErrors, Ablation: arm,
|
||||
}, &usage)
|
||||
a.LoadProjectionSidecar(f.Path)
|
||||
// From here the transcript lives only in memory. On disk it is the answer.
|
||||
sealFixture(f.Path)
|
||||
defer unsealFixture(f.Path)
|
||||
dir := filepath.Dir(f.Path)
|
||||
if at, marker := answerOnDisk(dir, inst.AnswerMarkers); at != "" {
|
||||
return contextMetrics{}, fmt.Errorf("%s [%s]: %q is readable at %s before the run", t.ID, armName, marker, at)
|
||||
}
|
||||
|
||||
before := len(sess.Snapshot())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), runTimeout)
|
||||
defer cancel()
|
||||
prompt := inst.Prompt
|
||||
if len(promptOverride) > 0 && promptOverride[0] != "" {
|
||||
prompt = promptOverride[0]
|
||||
}
|
||||
runErr := a.Run(ctx, prompt)
|
||||
|
||||
if at, marker := answerOnDisk(dir, inst.AnswerMarkers); at != "" {
|
||||
return contextMetrics{}, fmt.Errorf("%s [%s]: %q was written to %s during the run", t.ID, armName, marker, at)
|
||||
}
|
||||
|
||||
appended := sess.Snapshot()[before:]
|
||||
m := scoreRun(appended, inst, f.Target, armName, cueVisible)
|
||||
m.trajectory = appended
|
||||
if u, requests := usage.snapshot(); requests > 0 {
|
||||
m.UsageReportedRequests, m.PromptTokens, m.CompletionTokens = requests, u.PromptTokens, u.CompletionTokens
|
||||
m.CacheHitTokens, m.CacheMissTokens, m.UsageEstimated = u.CacheHitTokens, u.CacheMissTokens, u.Estimated
|
||||
}
|
||||
m.scoreAnswer(finalAnswer(appended), inst)
|
||||
if runErr != nil {
|
||||
m.FailureStage = "RunError"
|
||||
m.TimeoutReason = timeoutReason(m)
|
||||
}
|
||||
return m, runErr
|
||||
}
|
||||
|
||||
// failedMetrics is what a run that never reached a transcript leaves behind. It
|
||||
// carries the run's identity and the stage that ended it, so the failure stays a
|
||||
// countable fact; Failed keeps it out of every statistic.
|
||||
func failedMetrics(t contextTask, armName string, err error) contextMetrics {
|
||||
return contextMetrics{
|
||||
Task: t.ID, Arm: armName, Failed: true,
|
||||
FailureStage: stageNoEvidence, Reason: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// finalAnswer is the last assistant text the turn produced.
|
||||
func finalAnswer(msgs []provider.Message) string {
|
||||
for _, m := range slices.Backward(msgs) {
|
||||
if m.Role == provider.RoleAssistant && strings.TrimSpace(m.Content) != "" {
|
||||
return m.Content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isolateWorkspace moves the process into an empty directory before any task
|
||||
// runs. The corpus lives in this repository, so an agent left here greps the
|
||||
// answers out of corpus.go — one did, quoting the file's own line about them
|
||||
// being nonces. Fixtures stay outside it too: a readable session file is the
|
||||
// canonical transcript, answer included.
|
||||
func isolateWorkspace() (func(), error) {
|
||||
dir, err := os.MkdirTemp("", "contextbench-workspace-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
readme := "This workspace is empty on purpose. The question is about earlier conversation,\nnot about any file here.\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte(readme), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previous, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() {
|
||||
_ = os.Chdir(previous)
|
||||
_ = os.RemoveAll(dir)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// runBoundaries runs only the pair each index task is built for: the narrowest
|
||||
// scale still addressing its cue, and the next one down. Each pair moves the
|
||||
// affordance and nothing else, where default-vs-off moves the whole index.
|
||||
|
||||
// The two arms get separate nonces: reusing one would leave the first arm's
|
||||
// answer in a host-readable history for the second to find.
|
||||
func runBoundaries(mode, root string) int {
|
||||
started := time.Now()
|
||||
planned := 0
|
||||
repo, _ := os.Getwd()
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 2
|
||||
}
|
||||
restore, ierr := isolateWorkspace()
|
||||
if ierr != nil {
|
||||
fmt.Fprintln(os.Stderr, "isolate workspace:", ierr)
|
||||
return 2
|
||||
}
|
||||
defer restore()
|
||||
p, perr := realProvider()
|
||||
if perr != nil {
|
||||
fmt.Fprintln(os.Stderr, perr)
|
||||
return 2
|
||||
}
|
||||
|
||||
var all []contextMetrics
|
||||
var cueSide, noCueSide []contextMetrics
|
||||
for _, t := range efficiencyTasks() {
|
||||
cue, noCue := boundaryPair(t.CueTier)
|
||||
for _, side := range []struct {
|
||||
scale string
|
||||
has bool
|
||||
}{{cue, true}, {noCue, false}} {
|
||||
name := "index-" + side.scale
|
||||
planned++
|
||||
m, err := runOne(p, t, armFor(side.scale, false), name, root, side.has, false)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, name, err)
|
||||
m = failedMetrics(t, name, err)
|
||||
}
|
||||
all = append(all, m)
|
||||
if side.has {
|
||||
cueSide = append(cueSide, m)
|
||||
} else {
|
||||
noCueSide = append(noCueSide, m)
|
||||
}
|
||||
fmt.Printf("%-24s %-14s cue=%-3v %-14s search=%d read=%d direct=%-5v recall-tok=%-5d%s escape=%d\n",
|
||||
t.ID, name, side.has, m.FailureStage, m.SearchCalls, m.ReadCalls,
|
||||
m.CueDirectRead, m.RecallReturnedTokens, promptTokenSuffix(m), m.EscapeCalls)
|
||||
}
|
||||
}
|
||||
reportDeltas(cueSide, noCueSide)
|
||||
fmt.Print(reportStopping(all))
|
||||
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Repo: repo}, all)
|
||||
}
|
||||
|
||||
// reportDeltas is the paired reading: each task against itself, then pooled.
|
||||
func reportDeltas(cueSide, noCueSide []contextMetrics) {
|
||||
fmt.Printf("\n## Paired deltas (cue present − cue absent)\n")
|
||||
fmt.Printf(" %-24s %-10s %-10s %-9s %-11s %s\n",
|
||||
"task", "recovery", "directread", "searches", "recall-tok", "escapes")
|
||||
for i := range cueSide {
|
||||
if i >= len(noCueSide) {
|
||||
break
|
||||
}
|
||||
a, b := cueSide[i], noCueSide[i]
|
||||
fmt.Printf(" %-24s %-10s %-10s %-9d %-11d %d\n", a.Task,
|
||||
deltaBool(a.AnswerRecovered, b.AnswerRecovered),
|
||||
deltaBool(a.CueDirectRead, b.CueDirectRead),
|
||||
a.SearchCalls-b.SearchCalls,
|
||||
a.RecallReturnedTokens-b.RecallReturnedTokens,
|
||||
a.EscapeCalls-b.EscapeCalls)
|
||||
}
|
||||
fmt.Println("\n## Pooled")
|
||||
fmt.Println(" ", summarize("cue-present", cueSide).line())
|
||||
fmt.Println(" ", summarize("cue-absent", noCueSide).line())
|
||||
fmt.Println("\n## Where each side looked first")
|
||||
fmt.Printf(" %-14s %s\n", "cue-present", countsLine(summarize("x", cueSide).Routes))
|
||||
fmt.Printf(" %-14s %s\n", "cue-absent", countsLine(summarize("x", noCueSide).Routes))
|
||||
}
|
||||
|
||||
func deltaBool(a, b bool) string {
|
||||
switch {
|
||||
case a == b:
|
||||
return "same"
|
||||
case a:
|
||||
return "+cue"
|
||||
default:
|
||||
return "-cue"
|
||||
}
|
||||
}
|
||||
|
||||
// runExperiment runs one experiment's tasks across its arms. A dry run drives
|
||||
// the same pipeline with a scripted provider: it proves fixture, run, scoring
|
||||
// and report hold together before any of it is paid for.
|
||||
func runExperiment(mode, experiment, root string, dry bool, tasks []contextTask) int {
|
||||
started := time.Now()
|
||||
planned := 0
|
||||
repo, _ := os.Getwd()
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 2
|
||||
}
|
||||
restore, err := isolateWorkspace()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "isolate workspace:", err)
|
||||
return 2
|
||||
}
|
||||
defer restore()
|
||||
var p provider.Provider = &scriptedProvider{}
|
||||
if !dry {
|
||||
real, rerr := realProvider()
|
||||
if rerr != nil {
|
||||
fmt.Fprintln(os.Stderr, rerr)
|
||||
return 2
|
||||
}
|
||||
p = real
|
||||
}
|
||||
byArm := map[string][]contextMetrics{}
|
||||
var all []contextMetrics
|
||||
|
||||
if experiment == experimentIndex {
|
||||
fmt.Print(fingerprint(dry))
|
||||
}
|
||||
for _, t := range tasks {
|
||||
if t.Experiment != experiment {
|
||||
continue
|
||||
}
|
||||
// Arms are shuffled inside each task block. Running six defaults, then
|
||||
// six halves, would make provider drift collinear with the axis under
|
||||
// test: a bad minute would land entirely on one arm.
|
||||
for _, arm := range shuffledArms(t) {
|
||||
cueVisible := t.Experiment == experimentIndex && cueExpectedVisible(t, arm.scale)
|
||||
planned++
|
||||
m, err := runOne(p, t, armFor(arm.scale, arm.searchOff), arm.name, root, cueVisible, dry)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, arm.name, err)
|
||||
m = failedMetrics(t, arm.name, err)
|
||||
}
|
||||
byArm[arm.name] = append(byArm[arm.name], m)
|
||||
all = append(all, m)
|
||||
fmt.Printf("%-24s %-14s %-18s search=%d hit=%d read=%d recall-tok=%d%s %v\n",
|
||||
t.ID, arm.name, m.FailureStage, m.SearchCalls, m.TargetSearchHits, m.ReadCalls,
|
||||
m.RecallReturnedTokens, promptTokenSuffix(m), m.UnexpectedWorkTools)
|
||||
}
|
||||
}
|
||||
reportFunnels(byArm)
|
||||
if experiment == experimentIndex {
|
||||
fmt.Print(reportDoseResponse(all))
|
||||
}
|
||||
fmt.Print(reportStopping(all))
|
||||
fmt.Print(auditQueries(all).report())
|
||||
fmt.Print(queryLines(all))
|
||||
if experiment == experimentIndex {
|
||||
reportBoundaries(all)
|
||||
}
|
||||
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Dry: dry, Repo: repo}, all)
|
||||
}
|
||||
|
||||
func cueExpectedVisible(t contextTask, scale string) bool {
|
||||
visible, _ := tierScales(t.CueTier)
|
||||
return contains(visible, scale)
|
||||
}
|
||||
|
||||
func reportFunnels(byArm map[string][]contextMetrics) {
|
||||
names := make([]string, 0, len(byArm))
|
||||
for name := range byArm {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
fmt.Println("\n## Funnel")
|
||||
for _, name := range names {
|
||||
fmt.Println(" ", summarize(name, byArm[name]).line())
|
||||
}
|
||||
fmt.Println("\n## Where it looked first")
|
||||
for _, name := range names {
|
||||
f := summarize(name, byArm[name])
|
||||
fmt.Printf(" %-14s %s\n", name, countsLine(f.Routes))
|
||||
}
|
||||
fmt.Println("\n## Where it broke")
|
||||
for _, name := range names {
|
||||
f := summarize(name, byArm[name])
|
||||
stages := make([]string, 0, len(f.Stages))
|
||||
for stage, n := range f.Stages {
|
||||
stages = append(stages, fmt.Sprintf("%s=%d", stage, n))
|
||||
}
|
||||
sort.Strings(stages)
|
||||
fmt.Printf(" %-12s %s\n", name, strings.Join(stages, " "))
|
||||
}
|
||||
}
|
||||
|
||||
// writeTrajectories saves what each turn produced, after the whole batch is
|
||||
// done. Three analyses in a row have needed a re-run because something was
|
||||
// counted and not kept — query text, then snippet contents. It is written at
|
||||
// the end on purpose: during the batch these files are the answer.
|
||||
func writeTrajectories(root string, all []contextMetrics) error {
|
||||
f, err := os.Create(filepath.Join(root, "trajectories.jsonl"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
for _, m := range all {
|
||||
if err := writeJSONLine(f, map[string]any{
|
||||
"task": m.Task, "arm": m.Arm, "messages": m.trajectory,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeResults(root string, info batchInfo, all []contextMetrics) int {
|
||||
path := filepath.Join(root, "results.jsonl")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 2
|
||||
}
|
||||
defer f.Close()
|
||||
scored, failed := 0, 0
|
||||
for _, m := range all {
|
||||
if err := writeJSONLine(f, m); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 2
|
||||
}
|
||||
if m.Failed {
|
||||
failed++
|
||||
} else {
|
||||
scored++
|
||||
}
|
||||
}
|
||||
if err := writeTrajectories(root, all); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write trajectories:", err)
|
||||
return 2
|
||||
}
|
||||
if err := writeBatchManifest(root, info, all); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write batch manifest:", err)
|
||||
return 2
|
||||
}
|
||||
// Both counts are named: a reader who sees only the batch size cannot tell
|
||||
// measurements from attempts.
|
||||
fmt.Printf("\n%d runs written to %s: scored=%d failed=%d (with trajectories.jsonl and batch.json)\n",
|
||||
len(all), path, scored, failed)
|
||||
return 0
|
||||
}
|
||||
|
||||
// reportBoundaries prints each index task across its own cue boundary, then the
|
||||
// two sides pooled. Task difficulty is held constant inside a row, which is
|
||||
// what a four-arm average cannot do.
|
||||
func reportBoundaries(all []contextMetrics) {
|
||||
byKey := map[string]contextMetrics{}
|
||||
scored, failed := 0, 0
|
||||
for _, m := range all {
|
||||
if m.Failed {
|
||||
// No transcript, no boundary to compare: count the failure instead of
|
||||
// printing the zero it left behind as a measurement.
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
scored++
|
||||
byKey[m.Task+"|"+m.Arm] = m
|
||||
}
|
||||
if scored == 0 {
|
||||
fmt.Printf("\n## Across each task's cue boundary\n no scored runs: scored=%d failed=%d (nothing to compare)\n", scored, failed)
|
||||
return
|
||||
}
|
||||
var cueSide, noCueSide []contextMetrics
|
||||
fmt.Println("\n## Across each task's cue boundary")
|
||||
fmt.Printf(" %-24s %-16s %-4s %-10s %-9s %-12s %-11s %s\n",
|
||||
"task", "arm", "cue", "recovered", "cue-read", "search/task", "recall-tok", "escape")
|
||||
for _, t := range indexTasks() {
|
||||
cue, noCue := boundaryPair(t.CueTier)
|
||||
for _, side := range []struct {
|
||||
scale string
|
||||
has bool
|
||||
}{{cue, true}, {noCue, false}} {
|
||||
m, ok := byKey[t.ID+"|index-"+side.scale]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if side.has {
|
||||
cueSide = append(cueSide, m)
|
||||
} else {
|
||||
noCueSide = append(noCueSide, m)
|
||||
}
|
||||
mark := "no"
|
||||
if side.has {
|
||||
mark = "yes"
|
||||
}
|
||||
fmt.Printf(" %-24s %-16s %-4s %-10v %-9v %-12d %-11d %d\n",
|
||||
t.ID, "index-"+side.scale, mark, m.AnswerRecovered, m.RecallReadWithoutSearch,
|
||||
m.SearchCalls, m.RecallReturnedTokens, m.EscapeCalls)
|
||||
}
|
||||
}
|
||||
fmt.Println("\n## Pooled across boundaries")
|
||||
fmt.Println(" ", summarize("cue-present", cueSide).line())
|
||||
fmt.Println(" ", summarize("cue-absent", noCueSide).line())
|
||||
}
|
||||
|
||||
// shuffledArms permutes a task's arms deterministically, so the order is
|
||||
// reproducible and still uncorrelated with the axis being measured.
|
||||
func shuffledArms(t contextTask) []experimentArm {
|
||||
arms := armsFor(t)
|
||||
rng := seededRand(t.ID, "arm-order")
|
||||
rng.Shuffle(len(arms), func(i, j int) { arms[i], arms[j] = arms[j], arms[i] })
|
||||
return arms
|
||||
}
|
||||
|
||||
// fingerprint records what produced a batch. Not a gate — but batch effects
|
||||
// have reversed three conclusions in this experiment already, and a table with
|
||||
// no provenance cannot be compared with the next one.
|
||||
func fingerprint(dry bool) string {
|
||||
model, endpoint := runModel, runBaseURL
|
||||
if dry {
|
||||
model, endpoint = "scripted", "none"
|
||||
}
|
||||
commit := "unknown"
|
||||
if out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output(); err == nil {
|
||||
commit = strings.TrimSpace(string(out))
|
||||
}
|
||||
return fmt.Sprintf("## Batch\n model=%s endpoint=%s window=%d generations=%d commit=%s started=%s\n",
|
||||
model, endpoint, fixtureWindow, fixtureGenerations, commit, time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// timeoutReason separates a run that never found its answer from one that found
|
||||
// it and kept going. Only the first is a case for a longer budget; the second
|
||||
// would just get more time to circle. A batch reporting only "context deadline
|
||||
// exceeded" cannot tell an arm that made retrieval harder from an arm that
|
||||
// happened to draw a slow minute.
|
||||
func timeoutReason(m contextMetrics) string {
|
||||
switch {
|
||||
case m.TargetHeldAtEnd && m.RoundsAfterTarget >= 2:
|
||||
return "PostTargetRunaway"
|
||||
case m.TargetHeldAtEnd:
|
||||
return "TargetHeldLate"
|
||||
case m.SearchCalls >= 3:
|
||||
return "SearchSpiral"
|
||||
case m.SearchCalls+m.ReadCalls+m.EscapeCalls == 0:
|
||||
return "NoToolActivity"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// scriptedProvider walks the chain a competent model would: search, read the
|
||||
// best hit, answer with what came back. It proves the pipeline holds together
|
||||
// for nothing, so a real run's first failure is about the model.
|
||||
|
||||
type scriptedProvider struct{}
|
||||
|
||||
func (p *scriptedProvider) Name() string { return "contextbench-scripted" }
|
||||
|
||||
var (
|
||||
scriptedHit = regexp.MustCompile(`(?m)^#(\d+) `)
|
||||
scriptedRecall = regexp.MustCompile(`(?s)^#\d+\n(.*)`)
|
||||
)
|
||||
|
||||
// Stream decides from the request alone, never from a call counter: one
|
||||
// provider serves every task in a run, and a counter would make the second task
|
||||
// start where the first left off.
|
||||
func (p *scriptedProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
ch := make(chan provider.Chunk, 3)
|
||||
defer close(ch)
|
||||
// Honour the schema, as a model does: an arm without a query parameter is
|
||||
// an arm that cannot search, and pretending otherwise scores a refusal as
|
||||
// a failed search.
|
||||
if !recallAccepts(req, "query") {
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "I have no address for that part of the history."}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
return ch, nil
|
||||
}
|
||||
// The last message, not the last tool result anywhere: the fixture's own
|
||||
// history is full of tool results, and one of those is not this turn's.
|
||||
last := ""
|
||||
if n := len(req.Messages); n > 0 && req.Messages[n-1].Role == provider.RoleTool {
|
||||
last = req.Messages[n-1].Content
|
||||
}
|
||||
switch {
|
||||
case last == "":
|
||||
args, _ := json.Marshal(map[string]string{"query": lastUserText(req)})
|
||||
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
|
||||
ID: "s1", Name: "recall", Arguments: string(args),
|
||||
}}
|
||||
case strings.Contains(last, "Folded context matching"):
|
||||
positions := scriptedHit.FindAllStringSubmatch(last, 1)
|
||||
if len(positions) == 0 {
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "Nothing in the folded history matches."}
|
||||
break
|
||||
}
|
||||
n, _ := strconv.Atoi(positions[0][1])
|
||||
args, _ := json.Marshal(map[string][]int{"positions": {n}})
|
||||
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
|
||||
ID: "r1", Name: "recall", Arguments: string(args),
|
||||
}}
|
||||
default:
|
||||
// Answer with what recall returned, so a correct chain scores as one.
|
||||
body := last
|
||||
if m := scriptedRecall.FindStringSubmatch(last); len(m) > 1 {
|
||||
body = m[1]
|
||||
}
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "From the folded history: " + body}
|
||||
}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// recallAccepts reports whether recall is offered with the given parameter.
|
||||
func recallAccepts(req provider.Request, param string) bool {
|
||||
for _, t := range req.Tools {
|
||||
if t.Name != "recall" {
|
||||
continue
|
||||
}
|
||||
b, err := json.Marshal(t.Parameters)
|
||||
return err == nil && strings.Contains(string(b), `"`+param+`"`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func lastUserText(req provider.Request) string {
|
||||
for _, m := range slices.Backward(req.Messages) {
|
||||
if m.Role == provider.RoleUser {
|
||||
return m.Content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
// snippet.go — does a rank-1 hit actually hand over the answer?
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/retrieval"
|
||||
"reasonix/internal/contract/ablation"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/runtime/agent"
|
||||
)
|
||||
|
||||
// TargetHit is not EvidenceSufficient: a rank-1 hit can still hand over a
|
||||
// window too short to answer with, and searching again after that is correct.
|
||||
// Measured through the shipped recall path, not a copy of it.
|
||||
|
||||
// snippetWidths are the windows to compare. 240 is what ships.
|
||||
var snippetWidths = []int{120, 180, 240, 320, 480}
|
||||
|
||||
// historicalQueries are the queries these tasks actually drew in the pilots,
|
||||
// kept because a probe query is the host's phrasing and the model's was not.
|
||||
var historicalQueries = map[string][]string{
|
||||
"i03-coalescing": {"coalesce window marker probe", "job completion coalescing window"},
|
||||
"i04-recovery-fence": {"recovery fence marker epoch", "expected epoch", "recfence quartz"},
|
||||
}
|
||||
|
||||
type snippetReading struct {
|
||||
Task string
|
||||
Query string
|
||||
Width int
|
||||
Covered int
|
||||
Total int
|
||||
Runes int
|
||||
Rank int
|
||||
Complete bool
|
||||
}
|
||||
|
||||
// runSnippetAudit answers the question offline, over many instantiations so a
|
||||
// single seed's lengths cannot decide it.
|
||||
func runSnippetAudit(root string, seeds int) int {
|
||||
fmt.Printf("## Snippet coverage (%d instantiations per task)\n", seeds)
|
||||
fmt.Println(" Does the window a rank-1 hit returns contain every value the task scores on?")
|
||||
|
||||
byTaskWidth := map[string]map[int][]snippetReading{}
|
||||
for _, t := range allTasks() {
|
||||
byTaskWidth[t.ID] = map[int][]snippetReading{}
|
||||
for seed := range seeds {
|
||||
inst, err := instantiateTask(t, seededRand(t.ID, "snippet", fmt.Sprint(seed)))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 2
|
||||
}
|
||||
queries := append([]string{inst.ProbeQuery}, historicalQueries[t.ID]...)
|
||||
for _, q := range queries {
|
||||
for _, width := range snippetWidths {
|
||||
r := measureSnippet(inst, q, width)
|
||||
byTaskWidth[t.ID][width] = append(byTaskWidth[t.ID][width], r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n %-24s %-5s", "task", "vals")
|
||||
for _, w := range snippetWidths {
|
||||
fmt.Printf("%9d", w)
|
||||
}
|
||||
fmt.Println(" mean runes @240")
|
||||
for _, t := range allTasks() {
|
||||
vals := len(t.AnswerVars)
|
||||
fmt.Printf(" %-24s %-5d", t.ID, vals)
|
||||
for _, w := range snippetWidths {
|
||||
fmt.Printf("%8.0f%%", 100*completeRate(byTaskWidth[t.ID][w]))
|
||||
}
|
||||
fmt.Printf(" %.0f\n", meanRunes(byTaskWidth[t.ID][240]))
|
||||
}
|
||||
reportShippedPath(root)
|
||||
return 0
|
||||
}
|
||||
|
||||
// measureSnippet renders one hit through the same MakeSnippet the recall path
|
||||
// uses, then asks whether every scored value survived the window.
|
||||
func measureSnippet(inst fixtureInstance, query string, width int) snippetReading {
|
||||
terms, err := retrieval.QueryTerms(query)
|
||||
if err != nil {
|
||||
return snippetReading{Task: inst.Task.ID, Query: query, Width: width, Total: len(inst.AnswerMarkers)}
|
||||
}
|
||||
// The tool result is the document the answer lives in; a call's arguments
|
||||
// are a separate document and never carry it.
|
||||
snippet := retrieval.MakeSnippet(inst.body, query, terms, width)
|
||||
covered := 0
|
||||
for _, marker := range inst.AnswerMarkers {
|
||||
if strings.Contains(strings.ToLower(snippet), strings.ToLower(marker)) {
|
||||
covered++
|
||||
}
|
||||
}
|
||||
return snippetReading{
|
||||
Task: inst.Task.ID, Query: query, Width: width,
|
||||
Covered: covered, Total: len(inst.AnswerMarkers),
|
||||
Runes: len([]rune(snippet)), Complete: covered == len(inst.AnswerMarkers),
|
||||
}
|
||||
}
|
||||
|
||||
// reportShippedPath cross-checks the table against the real recall tool: a
|
||||
// fixture, a fold, and a search through the agent that ships.
|
||||
func reportShippedPath(root string) {
|
||||
fmt.Println("\n## Through the shipped recall path (width as shipped)")
|
||||
for _, t := range allTasks() {
|
||||
inst, err := instantiateTask(t, seededRand(t.ID, "snippet-live"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
f, err := buildFixture(inst, ablation.Set{}, filepath.Join(root, "snippet", t.ID, "session.jsonl"))
|
||||
if err != nil {
|
||||
fmt.Printf(" %-24s fixture: %v\n", t.ID, err)
|
||||
continue
|
||||
}
|
||||
a := agent.New(&digestProvider{}, tool.NewRegistry(), f.Session, agent.Options{
|
||||
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
|
||||
SessionPath: f.Path, KeepPolicy: agent.KeepErrors,
|
||||
}, event.Discard)
|
||||
a.LoadProjectionSidecar(f.Path)
|
||||
queries := append([]string{inst.ProbeQuery}, historicalQueries[t.ID]...)
|
||||
for _, q := range queries {
|
||||
res, err := a.RecallContext(context.Background(), tool.RecallRequest{Query: q})
|
||||
if err != nil {
|
||||
fmt.Printf(" %-24s %-34q recall: %v\n", t.ID, q, err)
|
||||
continue
|
||||
}
|
||||
covered := 0
|
||||
for _, marker := range inst.AnswerMarkers {
|
||||
if strings.Contains(strings.ToLower(res.Text), strings.ToLower(marker)) {
|
||||
covered++
|
||||
}
|
||||
}
|
||||
fmt.Printf(" %-24s %-34q %d/%d values in the result\n",
|
||||
t.ID, truncate(q, 32), covered, len(inst.AnswerMarkers))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func completeRate(rs []snippetReading) float64 {
|
||||
if len(rs) == 0 {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, r := range rs {
|
||||
if r.Complete {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return float64(n) / float64(len(rs))
|
||||
}
|
||||
|
||||
func meanRunes(rs []snippetReading) float64 {
|
||||
if len(rs) == 0 {
|
||||
return 0
|
||||
}
|
||||
total := 0
|
||||
for _, r := range rs {
|
||||
total += r.Runes
|
||||
}
|
||||
return float64(total) / float64(len(rs))
|
||||
}
|
||||
|
||||
// answerCoverage is what a run should have recorded all along: how much of the
|
||||
// scored answer a search actually handed over. rank=1 with coverage 1/3 is not
|
||||
// a stopping failure.
|
||||
func answerCoverage(text string, markers []string) int {
|
||||
covered := 0
|
||||
lower := strings.ToLower(text)
|
||||
for _, marker := range markers {
|
||||
if strings.Contains(lower, strings.ToLower(marker)) {
|
||||
covered++
|
||||
}
|
||||
}
|
||||
return covered
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// usageSink reads what a run cost from the events the agent already emits. Every
|
||||
// billed completion reports event.Usage — the round path, compaction, command
|
||||
// routing and failure diagnosis all do — so the bench reads the same record the
|
||||
// product reads instead of wrapping a provider and counting a second time.
|
||||
type usageSink struct {
|
||||
mu sync.Mutex
|
||||
requests int
|
||||
usage provider.Usage
|
||||
}
|
||||
|
||||
func (s *usageSink) Emit(e event.Event) {
|
||||
if e.Kind != event.Usage || e.Usage == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// One event is one completion that reported usage; RequestCount covers a
|
||||
// completion that merged attempts. A request that failed before reporting
|
||||
// leaves no event, so this is not a count of attempts.
|
||||
if e.Usage.RequestCount > 0 {
|
||||
s.requests += e.Usage.RequestCount
|
||||
} else {
|
||||
s.requests++
|
||||
}
|
||||
u := *e.Usage
|
||||
s.usage.PromptTokens += u.PromptTokens
|
||||
s.usage.CompletionTokens += u.CompletionTokens
|
||||
s.usage.TotalTokens += u.TotalTokens
|
||||
s.usage.CacheHitTokens += u.CacheHitTokens
|
||||
s.usage.CacheMissTokens += u.CacheMissTokens
|
||||
s.usage.ReasoningTokens += u.ReasoningTokens
|
||||
s.usage.Estimated = s.usage.Estimated || u.Estimated
|
||||
}
|
||||
|
||||
func (s *usageSink) snapshot() (provider.Usage, int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.usage, s.requests
|
||||
}
|
||||
|
||||
// promptTokenSuffix renders what a run cost only when something reported it. A
|
||||
// dry run reports nothing, and a trailing "prompt-tok=0" would read as a
|
||||
// measured zero rather than as no measurement.
|
||||
func promptTokenSuffix(m contextMetrics) string {
|
||||
if m.UsageReportedRequests == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" prompt-tok=%d", m.PromptTokens)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// The cost a run reports must be what the agent reported, summed across every
|
||||
// completion it billed — including one that merged attempts and one that could
|
||||
// only estimate. Any other event kind is not a cost.
|
||||
func TestUsageSinkAccumulatesWhatTheAgentReports(t *testing.T) {
|
||||
var sink usageSink
|
||||
sink.Emit(event.Event{}) // a kind that is not Usage
|
||||
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
|
||||
PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110, CacheHitTokens: 60, CacheMissTokens: 40,
|
||||
}})
|
||||
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
|
||||
PromptTokens: 7, CompletionTokens: 3, CacheHitTokens: 2, CacheMissTokens: 5, RequestCount: 2, Estimated: true,
|
||||
}})
|
||||
sink.Emit(event.Event{Kind: event.Usage}) // a Usage event with no payload is not a cost
|
||||
|
||||
usage, requests := sink.snapshot()
|
||||
if requests != 3 {
|
||||
t.Fatalf("requests = %d, want 3 (1 + the merged attempt's 2; the empty event adds none)", requests)
|
||||
}
|
||||
if usage.PromptTokens != 107 || usage.CompletionTokens != 13 || usage.TotalTokens != 110 {
|
||||
t.Fatalf("accumulated usage = %+v", usage)
|
||||
}
|
||||
if usage.CacheHitTokens != 62 || usage.CacheMissTokens != 45 {
|
||||
t.Fatalf("cache split = %+v", usage)
|
||||
}
|
||||
if !usage.Estimated {
|
||||
t.Fatal("Estimated was dropped when one completion reported an estimate")
|
||||
}
|
||||
}
|
||||
|
||||
// A dry run must not claim a cost it never paid, in the JSON or in the line a
|
||||
// person reads: an empty suffix, not "prompt-tok=0".
|
||||
func TestUsageSinkStaysEmptyWithoutUsage(t *testing.T) {
|
||||
var sink usageSink
|
||||
sink.Emit(event.Event{})
|
||||
usage, requests := sink.snapshot()
|
||||
if requests != 0 || usage.PromptTokens != 0 || usage.CompletionTokens != 0 {
|
||||
t.Fatalf("nothing reported usage, got %+v requests=%d", usage, requests)
|
||||
}
|
||||
if suffix := promptTokenSuffix(contextMetrics{}); suffix != "" {
|
||||
t.Fatalf("a run with no reported usage still printed %q", suffix)
|
||||
}
|
||||
if suffix := promptTokenSuffix(contextMetrics{UsageReportedRequests: 1, PromptTokens: 12}); suffix != " prompt-tok=12" {
|
||||
t.Fatalf("a reported cost printed %q", suffix)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,452 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/agentgraph"
|
||||
"reasonix/internal/session/control"
|
||||
"reasonix/internal/state/execjournal"
|
||||
)
|
||||
|
||||
// armFleetActiveStore asks whether the sub-agent store can be asked about a
|
||||
// fan-out item whose slot was granted and whose child reached the provider. It
|
||||
// is not a parity arm: the store is what every recovery path reads, so what is
|
||||
// measured is one store answering differently about executions it holds the
|
||||
// same evidence for.
|
||||
const armFleetActiveStore = "fleet-active-store"
|
||||
|
||||
// activeFleetCallID is the fan-out under measurement. The lone delegation in
|
||||
// the same turn keeps taskCallID, so the two entry points stay separable in
|
||||
// every artifact without a naming scheme of their own.
|
||||
const activeFleetCallID = "probe_fleet_active"
|
||||
|
||||
// probeFleetChildEntered is the fan-out's half of the entry evidence. The lone
|
||||
// delegation's rides probeChildEntered, which already means exactly this for
|
||||
// the delegation under measurement.
|
||||
const probeFleetChildEntered = "probe:fleet-child-entered"
|
||||
|
||||
// The four populations one death holds. They are read off the journal rather
|
||||
// than off the item ids the dispatch declared: which of the two held-back items
|
||||
// wins the freed slot is the scheduler's to decide, and an arm that named them
|
||||
// in advance would be reporting a race as a premise.
|
||||
type population string
|
||||
|
||||
const (
|
||||
popSettled population = "fan-out settled"
|
||||
popRunning population = "fan-out executing"
|
||||
popRefused population = "fan-out refused"
|
||||
popLone population = "lone executing"
|
||||
popNone population = ""
|
||||
)
|
||||
|
||||
// measured are the four in the order they are reported, which is also the order
|
||||
// they become true: an item settles, the slot it frees admits one of the two
|
||||
// behind it, and the delegation holding the other slot outlives all of them.
|
||||
func measured() []population { return []population{popSettled, popRunning, popRefused, popLone} }
|
||||
|
||||
// populationOf places one journal entry in its population, or in none. Started
|
||||
// and open is executing; started and settled is done; queued without a start is an
|
||||
// item the scheduler held back and never admitted.
|
||||
func populationOf(e execjournal.Entry) population {
|
||||
switch {
|
||||
case ofLoneTask(e.ID):
|
||||
if e.Started() && e.Open() {
|
||||
return popLone
|
||||
}
|
||||
case ofActiveFleet(e.ID):
|
||||
switch {
|
||||
case e.Started() && !e.Open():
|
||||
return popSettled
|
||||
case e.Started() && e.Open():
|
||||
return popRunning
|
||||
case !e.Started() && e.Queued():
|
||||
return popRefused
|
||||
}
|
||||
}
|
||||
return popNone
|
||||
}
|
||||
|
||||
func ofActiveFleet(id string) bool { return strings.HasPrefix(id, activeFleetCallID+"/") }
|
||||
|
||||
// executionsIn are the ids this observation's journal places in a population,
|
||||
// in a stable order.
|
||||
func executionsIn(o Observation, pop population) []string {
|
||||
var out []string
|
||||
for _, e := range o.Executions {
|
||||
if populationOf(e) == pop {
|
||||
out = append(out, e.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// storedStatus is what the store says about one execution, empty when it has
|
||||
// never heard of it. The join is the execution id the child recorded as its
|
||||
// parent call, which is the same identity the journal opened it under.
|
||||
func storedStatus(o Observation, id string) string {
|
||||
for _, f := range o.Children.Facts {
|
||||
if f.ParentToolCallID == id {
|
||||
return f.Status
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// noRecord is what the store says about an execution it never recorded. It is
|
||||
// spelled out rather than left blank because a blank column reads as a row that
|
||||
// was not measured, which is the opposite of what an absent record means here.
|
||||
const noRecord = "no record"
|
||||
|
||||
func storeReading(o Observation, ids []string) string {
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
status := storedStatus(o, id)
|
||||
if status == "" {
|
||||
status = noRecord
|
||||
}
|
||||
out = append(out, id+"→"+status)
|
||||
}
|
||||
return orNone(join(out))
|
||||
}
|
||||
|
||||
// acknowledged reports whether the store holds a record for every one of these
|
||||
// executions. An empty set is not acknowledgement: a population the death never
|
||||
// produced is a premise that failed, and its row says not-measured.
|
||||
func acknowledged(o Observation, ids []string) bool {
|
||||
for _, id := range ids {
|
||||
if storedStatus(o, id) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(ids) > 0
|
||||
}
|
||||
|
||||
func activeStoreArm(name string) bool { return name == armFleetActiveStore }
|
||||
|
||||
// activeStoreRows read one store from four sides. Each population has the same
|
||||
// two columns — what the dying process's store said, and what the next one's
|
||||
// does — so the answers can only differ by which execution is being asked about.
|
||||
func activeStoreRows(before, after Observation) []row {
|
||||
return []row{
|
||||
populationsRow(before),
|
||||
childEntryRow(before),
|
||||
settledStoreRow(before, after),
|
||||
executingStoreRow(before, after),
|
||||
loneStoreRow(before, after),
|
||||
refusedStoreRow(before, after),
|
||||
listingRow(before, after),
|
||||
rebuildUnmovedRow(before, after),
|
||||
}
|
||||
}
|
||||
|
||||
// populationsRow is the arm's premise, not a durability claim: one death has to
|
||||
// hold all four states at once, or the comparisons below are between different
|
||||
// runs rather than between entry points.
|
||||
func populationsRow(before Observation) row {
|
||||
var got []string
|
||||
for _, pop := range measured() {
|
||||
got = append(got, fmt.Sprintf("%s=%d", pop, len(executionsIn(before, pop))))
|
||||
}
|
||||
return row{
|
||||
Semantic: "the four populations this death holds", Authority: journalAuthority,
|
||||
Artifact: "<stem>.execution.jsonl", Reconstruction: "in-process only",
|
||||
Before: join(got), After: "—", Verdict: activePremiseVerdict(before),
|
||||
}
|
||||
}
|
||||
|
||||
func activePremiseVerdict(before Observation) string {
|
||||
if activeStoreInvalid(before) != "" {
|
||||
return verdictNotMeasured
|
||||
}
|
||||
return verdictHolds
|
||||
}
|
||||
|
||||
// childEntryRow is the half of the evidence no artifact carries. A slot grant
|
||||
// says the scheduler admitted the item; only the child's own arrival at the
|
||||
// provider says the execution happened, and a store row read without it would
|
||||
// be asking about work that may never have run.
|
||||
func childEntryRow(before Observation) row {
|
||||
fleet := firstProgress(before, probeFleetChildEntered)
|
||||
lone := firstProgress(before, probeChildEntered)
|
||||
return row{
|
||||
Semantic: "the children that reached the provider", Authority: "the scripted provider, in-process",
|
||||
Artifact: "none observed", Reconstruction: "in-process only",
|
||||
Before: fmt.Sprintf("fan-out=%s lone=%s", fleet, lone),
|
||||
After: "—",
|
||||
Verdict: enteredVerdict(fleet == "true" && lone == "true"),
|
||||
}
|
||||
}
|
||||
|
||||
func enteredVerdict(both bool) string {
|
||||
if both {
|
||||
return verdictHolds
|
||||
}
|
||||
return verdictNotMeasured
|
||||
}
|
||||
|
||||
func firstProgress(o Observation, key string) string {
|
||||
if vals := o.Progress[key]; len(vals) > 0 {
|
||||
return vals[0]
|
||||
}
|
||||
return "unrecorded"
|
||||
}
|
||||
|
||||
// settledStoreRow is the control that makes the rest readable. It is the same
|
||||
// entry point, the same fan-out and the same store, differing only in that this
|
||||
// item finished — so if it too were absent, the finding would be about
|
||||
// persistence in general rather than about executions in flight.
|
||||
func settledStoreRow(before, after Observation) row {
|
||||
ids := executionsIn(before, popSettled)
|
||||
return row{
|
||||
Semantic: "a settled fan-out item, in the store", Authority: storeAuthority,
|
||||
Artifact: "subagents/<ref>.meta.json", Reconstruction: "ListSubagentsByParent, joined by execution id",
|
||||
Before: storeReading(before, ids),
|
||||
After: storeReading(after, ids),
|
||||
Verdict: controlVerdict(acknowledged(before, ids) && acknowledged(after, ids), len(ids) > 0),
|
||||
}
|
||||
}
|
||||
|
||||
// executingStoreRow is the arm. The conjunction is stated in the row rather
|
||||
// than left to the reader: the journal proves the slot was granted, the child
|
||||
// proves the execution happened, and this column is what the store can say
|
||||
// about it. All three, or the row is not the finding it looks like.
|
||||
func executingStoreRow(before, after Observation) row {
|
||||
ids := executionsIn(before, popRunning)
|
||||
entered := firstProgress(before, probeFleetChildEntered) == "true"
|
||||
return row{
|
||||
Semantic: "an executing fan-out item, in the store", Authority: storeAuthority,
|
||||
Artifact: "subagents/<ref>.meta.json",
|
||||
Reconstruction: "STARTED in the journal, entered at the provider, and asked of the store",
|
||||
Before: storeReading(before, ids),
|
||||
After: storeReading(after, ids),
|
||||
Verdict: executingVerdict(before, after, ids, entered),
|
||||
}
|
||||
}
|
||||
|
||||
// executingVerdict refuses the two readings that would overstate the row. An
|
||||
// execution nothing proves entered is not a silent store, and a population the
|
||||
// death never produced is not evidence of anything.
|
||||
func executingVerdict(before, after Observation, ids []string, entered bool) string {
|
||||
switch {
|
||||
case len(ids) == 0 || !entered:
|
||||
return verdictNotMeasured
|
||||
case acknowledged(before, ids) || acknowledged(after, ids):
|
||||
return verdictHolds
|
||||
default:
|
||||
return verdictViolated
|
||||
}
|
||||
}
|
||||
|
||||
// loneStoreRow is the other entry point, in the same death and the same store.
|
||||
// It is what makes "selectively blind" a statement about the store rather than
|
||||
// a comparison between two probe runs.
|
||||
func loneStoreRow(before, after Observation) row {
|
||||
ids := executionsIn(before, popLone)
|
||||
return row{
|
||||
Semantic: "an executing lone delegation, in the store", Authority: storeAuthority,
|
||||
Artifact: "subagents/<ref>.meta.json", Reconstruction: "the same read, one entry point over",
|
||||
Before: storeReading(before, ids),
|
||||
After: storeReading(after, ids),
|
||||
Verdict: controlVerdict(acknowledged(before, ids) && acknowledged(after, ids), len(ids) > 0),
|
||||
}
|
||||
}
|
||||
|
||||
// refusedStoreRow is the negative control, and it bounds any fix this arm might
|
||||
// support. An item the scheduler never admitted produced nothing, so the store
|
||||
// is owed no record for it — and a change that recorded every opening would
|
||||
// satisfy the row above by reintroducing exactly that.
|
||||
func refusedStoreRow(before, after Observation) row {
|
||||
ids := executionsIn(before, popRefused)
|
||||
silent := !acknowledged(before, ids) && !acknowledged(after, ids)
|
||||
return row{
|
||||
Semantic: "a refused fan-out item, in the store", Authority: crossAuthority,
|
||||
Artifact: "subagents/<ref>.meta.json, <stem>.execution.jsonl",
|
||||
Reconstruction: "queued, never started: the store must stay silent",
|
||||
Before: storeReading(before, ids),
|
||||
After: storeReading(after, ids),
|
||||
Verdict: controlVerdict(silent, len(ids) > 0),
|
||||
}
|
||||
}
|
||||
|
||||
func controlVerdict(ok bool, measured bool) string {
|
||||
switch {
|
||||
case !measured:
|
||||
return verdictNotMeasured
|
||||
case ok:
|
||||
return verdictHolds
|
||||
default:
|
||||
return verdictViolated
|
||||
}
|
||||
}
|
||||
|
||||
// listingRow is the surface a person or a model reaches for. It reads through
|
||||
// the workspace filter and the lineage walk the tool applies, which the store
|
||||
// read above does not — so it is checked against that read first: a listing
|
||||
// that cannot even see the settled item is a filter answering, not a store.
|
||||
func listingRow(before, after Observation) row {
|
||||
settled := executionsIn(before, popSettled)
|
||||
return row{
|
||||
Semantic: "what list_subagents would show", Authority: "SubagentStore.ListForParent",
|
||||
Artifact: "subagents/<ref>.meta.json",
|
||||
Reconstruction: "the read behind the tool, filtered by workspace and lineage",
|
||||
Before: listingReading(before),
|
||||
After: listingReading(after),
|
||||
Verdict: listingVerdict(before, after, settled),
|
||||
}
|
||||
}
|
||||
|
||||
func listingReading(o Observation) string {
|
||||
if o.Listing.Err != "" {
|
||||
return "error: " + o.Listing.Err
|
||||
}
|
||||
out := make([]string, 0, len(o.Listing.Rows))
|
||||
for _, r := range o.Listing.Rows {
|
||||
out = append(out, orDash(r.From)+"="+r.Status)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return orNone(join(out))
|
||||
}
|
||||
|
||||
// listingVerdict answers only once the listing has been shown to work. Its
|
||||
// filters can drop every row for reasons that have nothing to do with this arm,
|
||||
// and an empty listing read as an invisible execution is the substitution the
|
||||
// probe exists to refuse.
|
||||
func listingVerdict(before, after Observation, settled []string) string {
|
||||
if len(settled) == 0 || !listingHas(after, settled) {
|
||||
return verdictNotMeasured
|
||||
}
|
||||
if listingHas(before, executionsIn(before, popRunning)) {
|
||||
return verdictHolds
|
||||
}
|
||||
return verdictViolated
|
||||
}
|
||||
|
||||
func listingHas(o Observation, ids []string) bool {
|
||||
for _, id := range ids {
|
||||
found := false
|
||||
for _, r := range o.Listing.Rows {
|
||||
if r.From == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(ids) > 0
|
||||
}
|
||||
|
||||
// rebuildUnmovedRow pins what a fix must not move. A store that learned to
|
||||
// record executions in flight would leave stale records behind for a restart to
|
||||
// fold, and the one outcome this whole line of work exists to prevent is a
|
||||
// rebuild that answers with a node the dead process was running.
|
||||
func rebuildUnmovedRow(before, after Observation) row {
|
||||
rebuilt := rebuiltGraph(after)
|
||||
var ghosts []string
|
||||
for _, n := range rebuilt.Graph.Nodes {
|
||||
if n.State == agentgraph.StateRunning {
|
||||
ghosts = append(ghosts, n.ID)
|
||||
}
|
||||
}
|
||||
var cut []string
|
||||
for _, i := range rebuilt.Interrupted {
|
||||
if ofActiveFleet(i.Execution) || ofLoneTask(i.Execution) {
|
||||
cut = append(cut, i.Execution+":"+interruptionKind(i))
|
||||
}
|
||||
}
|
||||
sort.Strings(ghosts)
|
||||
sort.Strings(cut)
|
||||
return row{
|
||||
Semantic: "no ghost, and how the rebuild names the cut", Authority: rebuildAuthority,
|
||||
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
|
||||
Reconstruction: "execgraph.Rebuild over both, after the death",
|
||||
Before: fmt.Sprintf("%d executing at death", len(executionsIn(before, popRunning))+len(executionsIn(before, popLone))),
|
||||
After: fmt.Sprintf("ghosts=%s cut=%s", orNone(join(ghosts)), orNone(join(cut))),
|
||||
Verdict: controlVerdict(len(ghosts) == 0 && len(cut) > 0, len(before.Executions) > 0),
|
||||
}
|
||||
}
|
||||
|
||||
// activeStoreInvalid reports the premise this arm could not establish. All four
|
||||
// populations in one death is the whole design: without them the rows compare
|
||||
// different runs, and a row that compares nothing must not be read as green.
|
||||
func activeStoreInvalid(before Observation) string {
|
||||
for _, pop := range measured() {
|
||||
if len(executionsIn(before, pop)) == 0 {
|
||||
return "the death held nothing in population " + string(pop)
|
||||
}
|
||||
}
|
||||
if firstProgress(before, probeFleetChildEntered) != "true" {
|
||||
return "no fan-out child reached the provider, so no execution is proven to have happened"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runActiveStoreConstruct drives one delegation and one fan-out under a ceiling
|
||||
// they share, and dies with all four populations standing. The delegation goes
|
||||
// first and hangs: it is what keeps the ceiling from emptying under the fan-out,
|
||||
// so the item refused admission stays refused rather than starting a moment
|
||||
// later and taking the arm's negative control with it.
|
||||
func runActiveStoreConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
|
||||
prompt := fanOutTurn(turn+1, taskSentinel+" "+fleetActiveSentinel)
|
||||
go func() { _ = ctrl.Run(ctx, prompt) }()
|
||||
if err := waitForPopulations(ctrl, prov); err != nil {
|
||||
return err
|
||||
}
|
||||
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
|
||||
// Whether each child reached the provider is known only here, and it is half
|
||||
// of what makes a silent store a finding rather than a missing record for
|
||||
// work that never ran.
|
||||
obs.Progress[probeFleetChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(childHang)))}
|
||||
obs.Progress[probeChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(childHold)))}
|
||||
if err := writeObservation(root, obs); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForPopulations blocks until the journal holds all four states, then holds
|
||||
// still and asks again. A refusal is a state nothing announces, so the second
|
||||
// reading is what separates an item the scheduler is holding back from one that
|
||||
// was merely slow to be admitted.
|
||||
func waitForPopulations(ctrl *control.Controller, prov *scripted) error {
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if populationsStanding(ctrl, prov) {
|
||||
time.Sleep(2 * time.Second)
|
||||
if !populationsStanding(ctrl, prov) {
|
||||
return errUnexpected("a refusal that was still standing",
|
||||
journalStates(control.ExecutionHistory(ctrl.SessionPath())))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return errUnexpected("one death holding all four populations",
|
||||
journalStates(control.ExecutionHistory(ctrl.SessionPath())))
|
||||
}
|
||||
|
||||
func populationsStanding(ctrl *control.Controller, prov *scripted) bool {
|
||||
held := map[population]bool{}
|
||||
for _, e := range control.ExecutionHistory(ctrl.SessionPath()) {
|
||||
held[populationOf(e)] = true
|
||||
}
|
||||
return held[popSettled] && held[popRunning] && held[popRefused] && held[popLone] &&
|
||||
prov.fleets.held(childEntered(childHang)) && prov.fleets.held(childEntered(childHold))
|
||||
}
|
||||
|
||||
func journalStates(entries []execjournal.Entry) string {
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, fmt.Sprintf("%s:started=%t,queued=%t,open=%t", e.ID, e.Started(), e.Queued(), e.Open()))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return orNone(join(out))
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/state/memory"
|
||||
)
|
||||
|
||||
// arm is one death-and-resume run. Lever runs between the two processes and is
|
||||
// what separates the three compaction questions: nothing changed, only the
|
||||
// stable prefix changed, the covered conversation changed.
|
||||
type arm struct {
|
||||
name string
|
||||
asks string
|
||||
lever func(armRoot, Observation) error
|
||||
}
|
||||
|
||||
// appendsAfterFold names the arms whose construct phase adds one turn after
|
||||
// the fold. Every arm carrying a lever needs it: without it the projection is
|
||||
// already gone at the boundary for an unrelated reason, and the lever's own
|
||||
// effect cannot be read out of the result.
|
||||
func appendsAfterFold(name string) bool {
|
||||
return name != "exact" && name != armRefoldIntoBody && !graphArm(name) && !uiArm(name) &&
|
||||
!schedulerWaitArm(name) && !terminalArm(name) && !deriveArm(name) &&
|
||||
!loneTaskArm(name) && !cancelRoutingArm(name) && !lineageArm(name) && !activeStoreArm(name) &&
|
||||
!skillArm(name) && !ephemeralArm(name) && !hostExecArm(name)
|
||||
}
|
||||
|
||||
// armAppendAfterFold separates the two ways a projection can fail validation
|
||||
// after a restart: a covered prefix that no longer matches, and a projection
|
||||
// that covers the whole transcript, whose only cross-process validation is a
|
||||
// transcript version the reload does not carry.
|
||||
const armAppendAfterFold = "append-after-fold"
|
||||
|
||||
// armRefoldIntoBody folds twice. The second fold reads the provider-visible
|
||||
// view — stored body plus live tail — and must record a boundary in canonical
|
||||
// terms, which the body has no counterpart for.
|
||||
const armRefoldIntoBody = "refold-into-body"
|
||||
|
||||
// The tail arms separate two ways the live tail can move. tail-edit and
|
||||
// tail-truncate change the transcript past the fold, where the covered hash
|
||||
// cannot notice; tail-rewind takes the path a person actually drives, which
|
||||
// throws the projection away without asking whether coverage still holds.
|
||||
const (
|
||||
armTailEdit = "tail-edit"
|
||||
armTailTruncate = "tail-truncate"
|
||||
armTailRewind = "tail-rewind"
|
||||
// armCoveredRewind is the negative control the rewind path will need before
|
||||
// its invalidation can become conditional: rewinding below the fold removes
|
||||
// history the digest folded, and no coverage check may excuse that.
|
||||
armCoveredRewind = "covered-rewind"
|
||||
// armTodoIdentity moves host step identity while a fold is between the
|
||||
// model and it: identity A, fold, live tail, identity B, fold again.
|
||||
armTodoIdentity = "todo-identity"
|
||||
// armOpenDecision dies while a question is open. It asks what the host
|
||||
// believes afterwards, not whether one particular card comes back.
|
||||
armOpenDecision = "open-decision"
|
||||
// The three successors to an interrupted barrier. Each starts where
|
||||
// open-decision ends and differs only in what the next process does, which
|
||||
// is the whole question: when does a recorded interruption stop being the
|
||||
// context every later request carries?
|
||||
armIdleRestart = "interrupted-idle"
|
||||
armUnrelatedTurn = "interrupted-unrelated"
|
||||
armDirectAnswer = "interrupted-answered"
|
||||
)
|
||||
|
||||
// successorTurn is what the middle process says, empty when it only restarts.
|
||||
func successorTurn(arm string) (string, bool) {
|
||||
switch arm {
|
||||
case armIdleRestart:
|
||||
return "", true
|
||||
case armUnrelatedTurn:
|
||||
return marker(120) + " List the files you would look at first.", true
|
||||
case armDirectAnswer:
|
||||
return marker(121) + " Yes, go ahead with that — take the side below the fold.", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func arms() []arm {
|
||||
return []arm{
|
||||
{name: "exact", asks: "nothing changed between the processes"},
|
||||
{name: armAppendAfterFold, asks: "nothing changed, and one turn was appended after the fold"},
|
||||
{name: armRefoldIntoBody, asks: "a second fold reaches into the body the first one stored"},
|
||||
{name: "system-swap", asks: "only the stable prefix changed, against the surviving baseline", lever: swapSystemPrefix},
|
||||
{name: armTailEdit, asks: "a message past the fold changed", lever: editTailRow},
|
||||
{name: armTailTruncate, asks: "the messages past the fold were dropped", lever: truncateTail},
|
||||
{name: armTailRewind, asks: "the same truncation, driven through the rewind a person uses"},
|
||||
{name: armCoveredRewind, asks: "a rewind that lands below the fold boundary"},
|
||||
{name: armTodoIdentity, asks: "host step identity moved across two folds and a live tail"},
|
||||
{name: armOpenDecision, asks: "the process died while a decision was open"},
|
||||
{name: armIdleRestart, asks: "an interrupted barrier, and nobody does anything"},
|
||||
{name: armUnrelatedTurn, asks: "an interrupted barrier, then a turn about something else"},
|
||||
{name: armDirectAnswer, asks: "an interrupted barrier, then a turn that reads like an answer to it"},
|
||||
{name: armGraphCompleted, asks: "a fan-out whose items had all settled when the process died"},
|
||||
{name: armGraphRunning, asks: "a fan-out with a child still executing when the process died"},
|
||||
{name: armGraphMixed, asks: "a fan-out holding completed, failed, adopted and running at once"},
|
||||
{name: armUIGraphMixed, asks: "which door each fact reaches the frontend through, after a death"},
|
||||
{name: armTaskCompleted, asks: "a lone delegation that finished, in a turn that closed"},
|
||||
{name: armTaskRunning, asks: "a lone delegation still executing when the process died"},
|
||||
{name: armTaskFgQueued, asks: "what a lone delegation is drawn as while the scheduler holds it back"},
|
||||
{name: armTaskBgQueued, asks: "a backgrounded delegation the ceiling refused, with its job already handed over"},
|
||||
{name: armTaskBgRunning, asks: "a backgrounded delegation executing inside its job"},
|
||||
{name: armCancelTool, asks: "whether a stop reaches an ordinary tool the turn is waiting on"},
|
||||
{name: armCancelBackground, asks: "whether a stop leaves work already handed to a job alone"},
|
||||
{name: armCancelHeadlessOwner, asks: "who owns the cancellation of a turn the controller never admitted"},
|
||||
{name: armLineageForeground, asks: "control: does a stop reach the tree the turn owns?"},
|
||||
{name: armLineageBackground, asks: "does it also reach the tree it handed away?"},
|
||||
{name: armLineageJobKill, asks: "control: does a job's own stop reach its tree?"},
|
||||
{name: armTaskFgQueuedCancel, asks: "a delegation stopped while the scheduler still held it back"},
|
||||
{name: armTaskBgQueuedCancel, asks: "the same, backgrounded: a job killed before it was ever admitted"},
|
||||
{name: armTaskFgRunningCancel, asks: "a delegation stopped while its child was executing"},
|
||||
{name: armTaskBgRunningCancel, asks: "the same, backgrounded: a job killed while its child ran"},
|
||||
{name: armWaitSlots, asks: "an item the session's total ceiling refused"},
|
||||
{name: armWaitWriters, asks: "an item the writer ceiling refused, with total capacity free"},
|
||||
{name: armWaitClaim, asks: "an item a path conflict refused, with both ceilings free"},
|
||||
{name: armWaitTransition, asks: "what a reported wait cause does when the thing it named stops holding the item"},
|
||||
{name: armTerminalAdopted, asks: "an item that never ran and still holds an answer"},
|
||||
{name: armTerminalSkippedDep, asks: "an item that never ran and holds none, because its dependency failed"},
|
||||
{name: armTerminalCancelled, asks: "an item a cancellation ended before it was admitted"},
|
||||
{name: armTerminalContext, asks: "whether a delivered answer's edge follows from what is already durable"},
|
||||
{name: armChildTerminal, asks: "whether the store keeps every terminal the graph draws"},
|
||||
{name: armDeriveSkipBoth, asks: "a skip whose two upstreams both ended without an answer"},
|
||||
{name: armDeriveSkipFlip, asks: "the same, with the two failures in the other order"},
|
||||
{name: armIdentitySemantics, asks: "what a node's model and effort report, across two producers and the store"},
|
||||
{name: armDeriveAnswered, asks: "a dependent whose upstreams answered, one by completing and one by adopting"},
|
||||
{name: armFleetActiveStore, asks: "whether the store can be asked about a fan-out item that is executing"},
|
||||
{name: armSkillQueued, asks: "whether a subagent skill asks the same scheduler as every other delegation"},
|
||||
{name: armSkillRunning, asks: "what names a subagent skill while its child is executing"},
|
||||
{name: armSkillCompleted, asks: "what a finished subagent skill leaves, through both entry points"},
|
||||
{name: armSkillCancel, asks: "what a stop reaches when a subagent skill is running"},
|
||||
{name: armReadOnlySkillQueued, asks: "whether the read-only skill entry point asks the same scheduler"},
|
||||
{name: armReadOnlySkillRunning, asks: "what names a read-only skill while its child is executing"},
|
||||
{name: armReadOnlySkillCompleted, asks: "what a finished read-only skill leaves behind, if anything"},
|
||||
{name: armEphemeralTask, asks: "can the execution authority account for what read_only_task draws?"},
|
||||
{name: armEphemeralSkill, asks: "the same of read_only_skill, the other ephemeral entry point"},
|
||||
{name: armHostRunning, asks: "what a slash-started subagent leaves when the process dies mid-execution"},
|
||||
{name: armHostQueued, asks: "what names slash-started work the scheduler is holding back"},
|
||||
{name: armHostCompleted, asks: "what a finished slash invocation leaves besides its answer"},
|
||||
{name: armHostCancel, asks: "what a stop reaches, and what history it leaves"},
|
||||
{name: armHeadlessCompleted, asks: "does the headless host surface leave the same provenance?"},
|
||||
{name: armHeadlessRunningCancel, asks: "the same, stopped through the context its caller holds"},
|
||||
{name: armHeadlessQueuedCancel, asks: "the same while the scheduler is still holding it back"},
|
||||
{name: "covered-mutation", asks: "the covered conversation changed, against the surviving baseline", lever: mutateCoveredRow},
|
||||
}
|
||||
}
|
||||
|
||||
// swapSystemPrefix saves a memory fact into this arm's home. The lever is a
|
||||
// real user action rather than a poked field: the saved-fact index folds into
|
||||
// the boot-time system prompt, so the next process composes a different one.
|
||||
func swapSystemPrefix(root armRoot, _ Observation) error {
|
||||
store := memory.StoreFor(config.RootsForHome(root.Home).MemoryUserDir(), root.Workspace)
|
||||
_, err := store.Save(memory.Memory{
|
||||
Name: "probe-prefix-lever",
|
||||
Title: "Probe prefix lever",
|
||||
Description: "A fact saved between the two processes so the composed system prompt differs.",
|
||||
Type: memory.TypeProject,
|
||||
Scope: memory.FactScopeProject,
|
||||
// Pinned, not relevant: only a pinned body rides the stable prefix. The
|
||||
// saved-fact index is retrieval-only and would move nothing.
|
||||
Activation: memory.ActivationPinned,
|
||||
Body: "The runtime-resume probe wrote this to move the stable prefix.",
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// mutateCoveredRow rewrites one provider-visible message inside the prefix the
|
||||
// projection folded, through the same rewrite save a rewind or a recovery
|
||||
// branch uses. This is the control arm: the digest's material changed, so the
|
||||
// projection must not be reused.
|
||||
func mutateCoveredRow(_ armRoot, before Observation) error {
|
||||
session, err := sessionstore.LoadSession(before.SessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msgs := session.Snapshot()
|
||||
covered := min(before.Sidecar.CoveredCount, len(msgs))
|
||||
target := -1
|
||||
for i := range covered {
|
||||
if msgs[i].Role == provider.RoleUser || msgs[i].Role == provider.RoleAssistant {
|
||||
target = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if target < 0 {
|
||||
return errUnexpected("a covered conversation row to mutate", covered)
|
||||
}
|
||||
msgs[target].Content += "\n[probe: covered row mutated]"
|
||||
session.Replace(msgs)
|
||||
return session.SaveRewrite(before.SessionPath)
|
||||
}
|
||||
|
||||
// editTailRow rewrites one message the projection does not claim to cover. The
|
||||
// covered hash cannot see it, so the projection should keep serving.
|
||||
func editTailRow(_ armRoot, before Observation) error {
|
||||
return rewriteSession(before, func(msgs []provider.Message, covered int) ([]provider.Message, error) {
|
||||
for i := covered; i < len(msgs); i++ {
|
||||
if msgs[i].Role == provider.RoleUser || msgs[i].Role == provider.RoleAssistant {
|
||||
msgs[i].Content += "\n[probe: tail row edited]"
|
||||
return msgs, nil
|
||||
}
|
||||
}
|
||||
return nil, errUnexpected("a live tail row to edit", covered)
|
||||
})
|
||||
}
|
||||
|
||||
// truncateTail drops everything past the fold boundary, leaving the covered
|
||||
// prefix exactly as the projection folded it.
|
||||
func truncateTail(_ armRoot, before Observation) error {
|
||||
return rewriteSession(before, func(msgs []provider.Message, covered int) ([]provider.Message, error) {
|
||||
return msgs[:covered], nil
|
||||
})
|
||||
}
|
||||
|
||||
// rewriteSession applies a transcript rewrite through the save a rewind or a
|
||||
// recovery branch uses, refusing an arm with no live tail to act on.
|
||||
func rewriteSession(before Observation, edit func([]provider.Message, int) ([]provider.Message, error)) error {
|
||||
session, err := sessionstore.LoadSession(before.SessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msgs := session.Snapshot()
|
||||
covered := before.Sidecar.CoveredCount
|
||||
if covered <= 0 || covered >= len(msgs) {
|
||||
return errUnexpected("a live tail past the fold", covered)
|
||||
}
|
||||
next, err := edit(msgs, covered)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session.Replace(next)
|
||||
return session.SaveRewrite(before.SessionPath)
|
||||
}
|
||||
|
||||
func orchestrate(work, only, jsonOut string, keep bool) error {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if work == "" {
|
||||
work, err = os.MkdirTemp("", "runtime-resume-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !keep {
|
||||
defer os.RemoveAll(work)
|
||||
}
|
||||
}
|
||||
var results []armResult
|
||||
for _, a := range arms() {
|
||||
if only != "" && a.name != only {
|
||||
continue
|
||||
}
|
||||
result, err := runArm(self, work, a)
|
||||
if err != nil {
|
||||
return fmt.Errorf("arm %s: %w", a.name, err)
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return fmt.Errorf("no arm matched %q", only)
|
||||
}
|
||||
fmt.Print(renderMatrix(results, work))
|
||||
return writeJSON(jsonOut, results)
|
||||
}
|
||||
|
||||
func runArm(self, work string, a arm) (armResult, error) {
|
||||
root := rootFor(filepath.Join(work, a.name))
|
||||
if err := root.requireFresh(); err != nil {
|
||||
return armResult{}, err
|
||||
}
|
||||
if err := root.create(); err != nil {
|
||||
return armResult{}, err
|
||||
}
|
||||
if err := spawn(self, "construct", root, a.name); err != nil {
|
||||
return armResult{}, fmt.Errorf("construct process: %w", err)
|
||||
}
|
||||
before, err := readObservation(root, "construct")
|
||||
if err != nil {
|
||||
return armResult{}, err
|
||||
}
|
||||
if a.lever != nil {
|
||||
if err := a.lever(root, before); err != nil {
|
||||
return armResult{}, fmt.Errorf("lever: %w", err)
|
||||
}
|
||||
}
|
||||
if _, ok := successorTurn(a.name); ok {
|
||||
if err := spawn(self, "successor", root, a.name); err != nil {
|
||||
return armResult{}, fmt.Errorf("successor process: %w", err)
|
||||
}
|
||||
}
|
||||
if err := spawn(self, "resume", root, a.name); err != nil {
|
||||
return armResult{}, fmt.Errorf("resume process: %w", err)
|
||||
}
|
||||
after, err := readObservation(root, "resume")
|
||||
if err != nil {
|
||||
return armResult{}, err
|
||||
}
|
||||
var extra *Observation
|
||||
if _, ok := successorTurn(a.name); ok {
|
||||
obs, err := readObservation(root, "successor")
|
||||
if err != nil {
|
||||
return armResult{}, err
|
||||
}
|
||||
extra = &obs
|
||||
} else if phase := extraPhase(a.name); phase != "" {
|
||||
obs, err := readObservation(root, phase)
|
||||
if err != nil {
|
||||
return armResult{}, err
|
||||
}
|
||||
extra = &obs
|
||||
}
|
||||
return classify(a, extra, before, after), nil
|
||||
}
|
||||
|
||||
// spawn runs one phase as a real child process and waits for it to exit. The
|
||||
// OS process is the measurement boundary: a nil-ed object can still be revived
|
||||
// by a package-level singleton, a live sink, or a cache, and none of those
|
||||
// survive here.
|
||||
func spawn(self, phase string, root armRoot, armName string) error {
|
||||
cmd := exec.Command(self, "-phase="+phase, "-root="+root.Dir, "-arm="+armName)
|
||||
cmd.Env = childEnv(root)
|
||||
cmd.Dir = root.Workspace
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func writeJSON(path string, results []armResult) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil
|
||||
}
|
||||
b, err := marshalResults(results)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, b, 0o644)
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
// Where a stop goes — and, first, whose stop it is. A controller owns the turns
|
||||
// it admits itself and cancels those; a synchronous Run belongs to the caller
|
||||
// that passed the context. Measuring the second with the first says nothing
|
||||
// about either.
|
||||
const (
|
||||
// armCancelTool is the control that says where a defect lives. If an
|
||||
// ordinary tool's context is cancelled and a delegation's is not, the fault
|
||||
// is in how a delegation derives its context; if neither is, it is upstream
|
||||
// of both.
|
||||
armCancelTool = "cancel-ordinary-tool"
|
||||
// armCancelBackground is the negative control, and the one that decides what
|
||||
// a fix may do: work already handed to a job has an owner of its own, and a
|
||||
// cancel that reached into it would take back the handoff.
|
||||
armCancelBackground = "cancel-background-handoff"
|
||||
// armCancelHeadlessOwner reads the other surface. A synchronous Run is never
|
||||
// admitted through the gate, so the controller holds no cancel for it and
|
||||
// the caller's own context holds the only one. Both halves are asserted: a
|
||||
// controller that started claiming this would be taking ownership from the
|
||||
// only holder that has it.
|
||||
armCancelHeadlessOwner = "cancel-headless-owner"
|
||||
)
|
||||
|
||||
func cancelRoutingArm(name string) bool {
|
||||
switch name {
|
||||
case armCancelTool, armCancelBackground, armCancelHeadlessOwner:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// guardedArm names the arms driven through a turn the controller admits itself,
|
||||
// which is the surface a person's Stop reaches.
|
||||
func guardedArm(name string) bool {
|
||||
return cancelTaskArm(name) || name == armCancelTool || name == armCancelBackground ||
|
||||
name == armSkillCancel || name == armHostCancel
|
||||
}
|
||||
|
||||
const (
|
||||
sleepSentinel = "PROBE-SLEEP"
|
||||
sleepCallID = "probe_sleep"
|
||||
// sleepSeconds outlasts the settling this probe waits through, so a shell
|
||||
// that ends promptly ended because something ended it.
|
||||
sleepSeconds = 30
|
||||
)
|
||||
|
||||
// sleepCall is an ordinary foreground tool that does nothing but wait. It is
|
||||
// the shell every turn can call, which is the point: it derives its context the
|
||||
// way every other tool does.
|
||||
func sleepCall() []provider.Chunk {
|
||||
args, _ := json.Marshal(map[string]any{
|
||||
"command": fmt.Sprintf("sleep %d", sleepSeconds),
|
||||
"description": "waits until something stops it",
|
||||
})
|
||||
return []provider.Chunk{{
|
||||
Type: provider.ChunkToolCall,
|
||||
ToolCall: &provider.ToolCall{ID: sleepCallID, Name: "bash", Arguments: string(args)},
|
||||
}}
|
||||
}
|
||||
|
||||
// runCancelRoutingConstruct drives the arm's work, stops it the way that
|
||||
// surface's owner stops it, and records what the stop reached. Nothing waits
|
||||
// for the work to end: whether it ends at all is the measurement.
|
||||
func runCancelRoutingConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
|
||||
sentinel := sleepSentinel
|
||||
if arm == armCancelBackground {
|
||||
sentinel = taskSentinel
|
||||
}
|
||||
prompt := fanOutTurn(turn+1, sentinel)
|
||||
ctx, cancelCaller := context.WithCancel(ctx)
|
||||
defer cancelCaller()
|
||||
if guardedArm(arm) {
|
||||
ctrl.Send(prompt)
|
||||
} else {
|
||||
go func() { _ = ctrl.Run(ctx, prompt) }()
|
||||
}
|
||||
if err := waitForCancelSubject(sink, prov, arm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
|
||||
obs.Progress[probeStopReached] = stopReadings(ctrl, sink, prov, arm, cancelCaller)
|
||||
if err := writeObservation(root, obs); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeStopReached carries what each stop reached into the observation. The key
|
||||
// names the probe so no row reads it as a status the kernel emitted.
|
||||
const probeStopReached = "probe:stop-reached"
|
||||
|
||||
// stopReadings issues this surface's stop and reports what it reached. The
|
||||
// headless arm issues both in order — the controller's, which owns nothing
|
||||
// there, and then the caller's, which owns everything.
|
||||
func stopReadings(ctrl *control.Controller, sink *graphSink, prov *scripted, arm string, cancelCaller context.CancelFunc) []string {
|
||||
// What the stop found before it was issued. Work already ended would make
|
||||
// every answer below meaningless, and this probe has made that mistake once.
|
||||
before := "live"
|
||||
if !workLive(sink, prov, arm) {
|
||||
before = "already gone"
|
||||
}
|
||||
ctrl.Cancel()
|
||||
first := "controller-cancel before=" + before + " " + reachedAfter(ctrl, sink, prov, arm)
|
||||
if arm != armCancelHeadlessOwner {
|
||||
return []string{first}
|
||||
}
|
||||
cancelCaller()
|
||||
return []string{first, "caller-cancel " + reachedAfter(ctrl, sink, prov, arm)}
|
||||
}
|
||||
|
||||
// reachedAfter waits out the settling this probe allows and reports what is
|
||||
// still live. The wait stays well below the shell's own sleep, so work that has
|
||||
// ended was ended by the stop rather than by finishing.
|
||||
func reachedAfter(ctrl *control.Controller, sink *graphSink, prov *scripted, arm string) string {
|
||||
stopped := time.Now()
|
||||
deadline := stopped.Add(8 * time.Second)
|
||||
for time.Now().Before(deadline) && workLive(sink, prov, arm) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
live := "gone"
|
||||
if workLive(sink, prov, arm) {
|
||||
live = "still running"
|
||||
}
|
||||
return fmt.Sprintf("turn=%s work=%s after=%s",
|
||||
turnStanding(ctrl, arm), live, time.Since(stopped).Round(time.Second))
|
||||
}
|
||||
|
||||
func workLive(sink *graphSink, prov *scripted, arm string) bool {
|
||||
if arm == armCancelBackground {
|
||||
return prov.fleets.held(childEntered(childHang)) && !prov.fleets.held(childLeft(childHang))
|
||||
}
|
||||
_, ended := sink.toolResult(sleepCallID)
|
||||
return !ended
|
||||
}
|
||||
|
||||
// turnStanding reports the gate only where the gate is the authority. A
|
||||
// synchronous Run is never admitted through it, so reading it there would say
|
||||
// "ended" about a turn it was never told about — which is how this probe first
|
||||
// mistook an ownership boundary for a cancellation defect.
|
||||
func turnStanding(ctrl *control.Controller, arm string) string {
|
||||
if !guardedArm(arm) {
|
||||
return "not admitted (caller-owned)"
|
||||
}
|
||||
if ctrl.Running() {
|
||||
return "still active"
|
||||
}
|
||||
return "ended"
|
||||
}
|
||||
|
||||
// waitForCancelSubject blocks until the work this arm is about to stop is
|
||||
// really under way: a stop that arrived before it would measure nothing.
|
||||
func waitForCancelSubject(sink *graphSink, prov *scripted, arm string) error {
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if arm == armCancelBackground {
|
||||
if prov.fleets.held(childEntered(childHang)) {
|
||||
return nil
|
||||
}
|
||||
} else if _, seen := sink.toolDispatched(sleepCallID); seen {
|
||||
// The shell starts with the dispatch, not with the event; a moment's
|
||||
// grace keeps the stop from racing its own subject.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return errUnexpected("work under way to stop", "nothing started")
|
||||
}
|
||||
|
||||
// cancelRoutingRows read the arm's own readings. The three arms want different
|
||||
// answers to the same question, which is why they run together: a fix that
|
||||
// cancels everything a session knows about passes one and fails two.
|
||||
func cancelRoutingRows(arm string, before Observation) []row {
|
||||
readings := before.Progress[probeStopReached]
|
||||
first, second := reading(readings, 0), reading(readings, 1)
|
||||
switch arm {
|
||||
case armCancelHeadlessOwner:
|
||||
return []row{
|
||||
ownershipRow("a controller stop on a turn it never admitted", first,
|
||||
"leaves the caller's work alone", stillRunning(first)),
|
||||
ownershipRow("the caller's own stop", second,
|
||||
"ends the work it owns", !stillRunning(second)),
|
||||
}
|
||||
case armCancelBackground:
|
||||
return []row{
|
||||
ownershipRow("the turn after a stop", first, "a cancelled turn ends",
|
||||
strings.Contains(first, "turn=ended")),
|
||||
ownershipRow("work already handed to a job", first,
|
||||
"a stop aimed at the turn leaves work the turn no longer owns", stillRunning(first)),
|
||||
}
|
||||
}
|
||||
return []row{
|
||||
ownershipRow("the turn after a stop", first, "a cancelled turn ends",
|
||||
strings.Contains(first, "turn=ended")),
|
||||
ownershipRow("the tool the turn was waiting on", first,
|
||||
"a stop reaches the work its turn is waiting on", !stillRunning(first)),
|
||||
}
|
||||
}
|
||||
|
||||
func ownershipRow(semantic, got, want string, ok bool) row {
|
||||
return row{
|
||||
Semantic: semantic, Authority: "the surface that owns this cancellation",
|
||||
Artifact: "none: in-process state", Reconstruction: want,
|
||||
Before: want, After: orNone(got), Verdict: held(ok),
|
||||
}
|
||||
}
|
||||
|
||||
func reading(readings []string, at int) string {
|
||||
if at < len(readings) {
|
||||
return readings[at]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stillRunning(reading string) bool { return strings.Contains(reading, "work=still running") }
|
||||
@@ -1,156 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// How a host can stand after dying with a question open. The names are the
|
||||
// outcomes, not a pass and its failures: two of them are designs a host may
|
||||
// legitimately choose, and only measurement says which one this is.
|
||||
const (
|
||||
outcomeResumable = "resumable" // the question is still there to answer
|
||||
outcomeInterrupted = "interrupted-explicit" // it is gone, and the host says a turn was cut
|
||||
outcomeLostSilent = "LOST-SILENT" // it is gone and nothing records that it existed
|
||||
)
|
||||
|
||||
// decisionRows classify what a restart inherited. The safety row comes first
|
||||
// because it is the only one with a single acceptable answer: whether the
|
||||
// decision survives is a design question, whether the effect it blocked stayed
|
||||
// blocked is not.
|
||||
func decisionRows(before, after Observation) []row {
|
||||
return []row{
|
||||
deferredRow(before, after),
|
||||
outcomeRow(before, after),
|
||||
barrierRow(before, after),
|
||||
modelContextRow(before, after),
|
||||
obligationRow(before, after),
|
||||
}
|
||||
}
|
||||
|
||||
// deferredRow asserts the write the question held back never ran — not while
|
||||
// the answer was pending, and not because a later process loaded the session.
|
||||
func deferredRow(before, after Observation) row {
|
||||
verdict := verdictHolds
|
||||
if before.Deferred.Executed || after.Deferred.Executed {
|
||||
verdict = verdictViolated
|
||||
}
|
||||
return row{
|
||||
Semantic: "the effect the decision held back", Authority: "the workspace",
|
||||
Artifact: before.Deferred.MarkerPath, Reconstruction: "the blocked write never runs, before or after the boundary",
|
||||
Before: ranOrNot(before), After: ranOrNot(after), Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
func ranOrNot(o Observation) string {
|
||||
if o.Deferred.Executed {
|
||||
return "RAN"
|
||||
}
|
||||
return "did not run"
|
||||
}
|
||||
|
||||
// outcomeRow names which of the three standings the restart produced. It is
|
||||
// stated, not judged: a host that drops the question and records the
|
||||
// interruption is a defensible design, and one that drops both is not.
|
||||
func outcomeRow(before, after Observation) row {
|
||||
verdict := verdictHolds
|
||||
var outcome string
|
||||
switch {
|
||||
case len(after.Decisions) > 0:
|
||||
outcome = outcomeResumable
|
||||
case len(after.Interrupted) > 0:
|
||||
outcome = outcomeInterrupted
|
||||
case after.Obligation.InterruptionMarked > 0 || len(after.Obligation.UnansweredCalls) > 0:
|
||||
outcome = outcomeInterrupted
|
||||
default:
|
||||
outcome = outcomeLostSilent
|
||||
verdict = verdictViolated
|
||||
}
|
||||
return row{
|
||||
Semantic: "what the restart inherited", Authority: "approvalManager and the transcript",
|
||||
Artifact: "none for the question; the transcript for the obligation",
|
||||
Reconstruction: "resumable, interrupted-explicit, or nothing at all",
|
||||
Before: decisionSummary(before), After: outcome, Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
func decisionSummary(o Observation) string {
|
||||
if len(o.Decisions) == 0 {
|
||||
return "no decision open"
|
||||
}
|
||||
var kinds []string
|
||||
for _, d := range o.Decisions {
|
||||
kinds = append(kinds, string(d.Kind))
|
||||
}
|
||||
return strings.Join(kinds, ", ") + " open"
|
||||
}
|
||||
|
||||
// barrierRow is the evidence behind an interrupted classification: a barrier
|
||||
// the host recorded before anyone could be asked, still open, with no owner in
|
||||
// this process waiting on it. It is deliberately not offered as answerable.
|
||||
func barrierRow(before, after Observation) row {
|
||||
return row{
|
||||
Semantic: "durable adjudication barriers", Authority: "the adjudication log",
|
||||
Artifact: "<stem>.adjudication.jsonl", Reconstruction: "open records with no live owner in this process",
|
||||
Before: barrierSummary(before), After: barrierSummary(after), Verdict: verdictStable,
|
||||
}
|
||||
}
|
||||
|
||||
func barrierSummary(o Observation) string {
|
||||
if len(o.Interrupted) == 0 {
|
||||
return "none"
|
||||
}
|
||||
var out []string
|
||||
for _, b := range o.Interrupted {
|
||||
out = append(out, b.Kind+":"+b.ID)
|
||||
}
|
||||
return join(out) + " (not answerable)"
|
||||
}
|
||||
|
||||
// modelContextRow is the other read surface: what the next request tells the
|
||||
// model. One block when an interruption stands, none when it does not, and it
|
||||
// must never arrive as something answerable.
|
||||
func modelContextRow(before, after Observation) row {
|
||||
verdict := verdictHolds
|
||||
if before.ModelSeesInterruption != expectedBlocks(before) || after.ModelSeesInterruption != expectedBlocks(after) {
|
||||
verdict = verdictViolated
|
||||
}
|
||||
return row{
|
||||
Semantic: "what the next request tells the model", Authority: "control, projected per request",
|
||||
Artifact: "none: derived, never stored", Reconstruction: "one block while an interruption stands, none otherwise",
|
||||
Before: modelContextSummary(before), After: modelContextSummary(after), Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
func expectedBlocks(o Observation) int {
|
||||
if len(o.Interrupted) > 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func modelContextSummary(o Observation) string {
|
||||
return fmt.Sprintf("%d block(s) for %d interruption(s)", o.ModelSeesInterruption, len(o.Interrupted))
|
||||
}
|
||||
|
||||
// obligationRow shows the evidence behind that classification, so a reader can
|
||||
// see what the host still says rather than trusting the label.
|
||||
func obligationRow(before, after Observation) row {
|
||||
return row{
|
||||
Semantic: "what the transcript still owes", Authority: "the canonical transcript",
|
||||
Artifact: "<stem>.jsonl", Reconstruction: "tool calls with no result, and interruption records",
|
||||
Before: obligationSummary(before), After: obligationSummary(after), Verdict: verdictStable,
|
||||
}
|
||||
}
|
||||
|
||||
func obligationSummary(o Observation) string {
|
||||
return fmt.Sprintf("unanswered %s, interruption records %d",
|
||||
orNone(join(o.Obligation.UnansweredCalls)), o.Obligation.InterruptionMarked)
|
||||
}
|
||||
|
||||
func orNone(s string) string {
|
||||
if s == "" {
|
||||
return "none"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/agentgraph"
|
||||
"reasonix/internal/session/control"
|
||||
"reasonix/internal/state/execgraph"
|
||||
)
|
||||
|
||||
// The derivability arms. Everything before them asked what survives; these ask
|
||||
// what still has to be written down. A fact a restart can compute from what is
|
||||
// already durable needs no record of its own, and adding one would give it a
|
||||
// second owner for nothing.
|
||||
const (
|
||||
// armDeriveSkipBoth cuts a branch with two upstreams that both end without
|
||||
// an answer. Which of them the graph names is the question: the durable
|
||||
// facts show two, and the picture shows one.
|
||||
armDeriveSkipBoth = "derive-skip-both"
|
||||
// armDeriveSkipFlip is the same graph with the two failures in the other
|
||||
// order. If the named cause moves while the durable facts do not, the cause
|
||||
// is history rather than a consequence of the end state.
|
||||
armDeriveSkipFlip = "derive-skip-flip"
|
||||
// armDeriveAnswered is the negative control and the context sample at once:
|
||||
// two upstreams that both answer, one by completing and one by adopting, so
|
||||
// the dependent must run and both deliveries must be accounted for.
|
||||
armDeriveAnswered = "derive-answered"
|
||||
)
|
||||
|
||||
func deriveArm(name string) bool {
|
||||
switch name {
|
||||
case armDeriveSkipBoth, armDeriveSkipFlip, armDeriveAnswered:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func deriveSentinels(arm string) string {
|
||||
if arm == armDeriveAnswered {
|
||||
return fleetWarmSentinel + " " + fleetDeriveSentinel
|
||||
}
|
||||
return fleetDeriveSentinel
|
||||
}
|
||||
|
||||
// runDeriveConstruct lets the fan-out settle and dies before the turn ends, the
|
||||
// way every terminal arm does: a skip is published only in the closing delta.
|
||||
func runDeriveConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
|
||||
runCtx, cancelRun := context.WithCancel(ctx)
|
||||
defer cancelRun()
|
||||
go func() { _ = ctrl.Run(runCtx, fanOutTurn(turn+1, deriveSentinels(arm))) }()
|
||||
if err := waitForDerive(sink, arm, prov); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeObservation(root, capture("construct", arm, bootSystem, ctrl, sink, root)); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForDerive holds until the arm's shape is on the graph. The skip arms
|
||||
// release their second failure once the first has been recorded, so which
|
||||
// upstream the fan-out names is decided rather than raced.
|
||||
func waitForDerive(sink *graphSink, arm string, prov *scripted) error {
|
||||
if arm == armDeriveAnswered {
|
||||
// Waiting on a count would be satisfied by the warm-up alone. The
|
||||
// delivery edge is what the arm compares, so it waits for that.
|
||||
return waitForShape(sink, func(g agentgraph.Graph) bool {
|
||||
return len(contextTargets(g)) > 0 && len(nodesInState(g, agentgraph.StateAdopted)) > 0
|
||||
}, "an adopted upstream and a delivered answer")
|
||||
}
|
||||
// The first failure decides which upstream is named — the fan-out cuts the
|
||||
// branch when it processes that result. Only then is the second released,
|
||||
// so the order is constructed rather than raced.
|
||||
if err := waitForShape(sink, func(g agentgraph.Graph) bool {
|
||||
return len(nodesInState(g, agentgraph.StateFailed)) > 0
|
||||
}, "the first upstream to fail"); err != nil {
|
||||
return err
|
||||
}
|
||||
prov.releaseOnce()
|
||||
// A skip is published only in the closing delta, so the group has to end
|
||||
// before the state the arm compares exists at all.
|
||||
return waitForShape(sink, func(g agentgraph.Graph) bool {
|
||||
return len(nodesInState(g, agentgraph.StateSkipped)) > 0 &&
|
||||
len(nodesInState(g, agentgraph.StateFailed)) >= 2
|
||||
}, "both upstreams failed and the dependent skipped")
|
||||
}
|
||||
|
||||
// contextTargets are the items the graph shows receiving an upstream answer.
|
||||
func contextTargets(g agentgraph.Graph) []string {
|
||||
var out []string
|
||||
for _, e := range g.Edges {
|
||||
if e.Kind == agentgraph.Context {
|
||||
out = append(out, e.To)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func waitForShape(sink *graphSink, ok func(agentgraph.Graph) bool, want string) error {
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
graph, _ := sink.snapshot()
|
||||
if ok(graph) {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
graph, _ := sink.snapshot()
|
||||
return errUnexpected(want, waitStates(graph))
|
||||
}
|
||||
|
||||
// answeredFromDurable is Answered() computed the way a restart would have to:
|
||||
// an adoption says so on its own opening, and everything else is the terminal
|
||||
// the sub-agent store kept, joined by the execution id the child records.
|
||||
func answeredFromDurable(o Observation) map[string]bool {
|
||||
terminal := map[string]string{}
|
||||
for _, f := range o.Children.Facts {
|
||||
if f.ParentToolCallID != "" {
|
||||
terminal[f.ParentToolCallID] = f.Status
|
||||
}
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, e := range o.Executions {
|
||||
out[e.ID] = e.Disposition == "adopted" || terminal[e.ID] == string(sessionstore.SubagentCompleted)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// impliedSkipped is the state a restart would derive: an item the orchestration
|
||||
// released without ever starting it, ordered behind something that did not
|
||||
// answer. Nothing here reads a disposition, because that is what is missing.
|
||||
func impliedSkipped(o Observation) string {
|
||||
answered := answeredFromDurable(o)
|
||||
var out []string
|
||||
for _, e := range o.Executions {
|
||||
if e.Started() || e.Queued() || e.SettledAt.IsZero() || len(e.DependsOn) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, up := range e.DependsOn {
|
||||
if !answered[up] {
|
||||
out = append(out, e.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
// impliedSkipCauses are every upstream that could have been named, and the two
|
||||
// rules that would pick one: declaration order, and the earliest release. A
|
||||
// cause is derivable only if some rule always agrees with the picture.
|
||||
func impliedSkipCauses(o Observation) (all, byOrder, bySettle string) {
|
||||
answered := answeredFromDurable(o)
|
||||
byID := map[string]int{}
|
||||
for i, e := range o.Executions {
|
||||
byID[e.ID] = i
|
||||
}
|
||||
var every, first, earliest []string
|
||||
for _, e := range o.Executions {
|
||||
if e.Started() || e.Queued() || e.SettledAt.IsZero() || len(e.DependsOn) == 0 {
|
||||
continue
|
||||
}
|
||||
var unanswered []string
|
||||
for _, up := range e.DependsOn {
|
||||
if !answered[up] {
|
||||
unanswered = append(unanswered, up)
|
||||
}
|
||||
}
|
||||
if len(unanswered) == 0 {
|
||||
continue
|
||||
}
|
||||
every = append(every, e.ID+"<-{"+strings.Join(unanswered, ",")+"}")
|
||||
first = append(first, e.ID+"<-"+unanswered[0])
|
||||
pick := unanswered[0]
|
||||
for _, up := range unanswered[1:] {
|
||||
if i, j := byID[up], byID[pick]; o.Executions[i].SettledAt.Before(o.Executions[j].SettledAt) {
|
||||
pick = up
|
||||
}
|
||||
}
|
||||
earliest = append(earliest, e.ID+"<-"+pick)
|
||||
}
|
||||
sort.Strings(every)
|
||||
sort.Strings(first)
|
||||
sort.Strings(earliest)
|
||||
return join(every), join(first), join(earliest)
|
||||
}
|
||||
|
||||
// actualSkipCause is the upstream the fan-out actually named, read off the node
|
||||
// the graph settled. It is the picture's own answer, not a derivation.
|
||||
func actualSkipCause(o Observation) string {
|
||||
var out []string
|
||||
for _, n := range o.Graph.Nodes {
|
||||
if n.State == agentgraph.StateSkipped && n.Err != "" {
|
||||
out = append(out, n.ID+": "+n.Err)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return orNone(join(out))
|
||||
}
|
||||
|
||||
// impliedContextFromDurable predicts the delivery edges from durable facts
|
||||
// alone: a started item receives an answer from every upstream that answered.
|
||||
func impliedContextFromDurable(o Observation) string {
|
||||
answered := answeredFromDurable(o)
|
||||
var out []string
|
||||
for _, e := range o.Executions {
|
||||
if !e.Started() {
|
||||
continue
|
||||
}
|
||||
for _, up := range e.DependsOn {
|
||||
if answered[up] {
|
||||
out = append(out, e.ID+"<-"+up)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
// deriveRows are the three questions, kept apart. A state that derives says
|
||||
// nothing about whether the cause does, and one arm's agreement says nothing
|
||||
// about a rule that has only ever seen one sample.
|
||||
func deriveRows(arm string, before, after Observation) []row {
|
||||
rows := []row{
|
||||
joinRow(before, after),
|
||||
skipStateRow(before, after),
|
||||
contextDeriveRow(before, after),
|
||||
}
|
||||
if arm != armDeriveAnswered {
|
||||
rows = append(rows, skipCauseRow(before, after))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// joinRow is what every derivation below stands on: the child records name the
|
||||
// execution they ran for, so a restart can ask the store what each item ended
|
||||
// as. Without it none of the other rows could be computed at all.
|
||||
func joinRow(before, after Observation) row {
|
||||
return valueRow("executions the store can be asked about", "child metadata's parent tool-call id",
|
||||
"subagents/<ref>.meta.json", "joined to the journal by execution id",
|
||||
joinedExecutions(before), joinedExecutions(after), true)
|
||||
}
|
||||
|
||||
func joinedExecutions(o Observation) string {
|
||||
var out []string
|
||||
for _, f := range o.Children.Facts {
|
||||
if f.ParentToolCallID != "" {
|
||||
out = append(out, f.ParentToolCallID+"="+f.Status)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
// skipStateRow compares the state a restart would derive against the one the
|
||||
// picture drew. Agreement means StateSkipped needs no record of its own.
|
||||
func skipStateRow(before, after Observation) row {
|
||||
actual := join(nodesInState(graphOf(before), agentgraph.StateSkipped))
|
||||
implied := impliedSkipped(after)
|
||||
verdict := verdictHolds
|
||||
if implied != actual {
|
||||
verdict = verdictViolated
|
||||
}
|
||||
return row{
|
||||
Semantic: "skipped state: derived against drawn", Authority: "the journal joined to the store",
|
||||
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
|
||||
Reconstruction: "settled, never started, ordered behind something that did not answer",
|
||||
Before: "drawn " + orNone(actual), After: "derived " + orNone(implied), Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
// skipCauseRow keeps the harder question separate, and reads it three ways: the
|
||||
// upstream the picture named, the two rules this file works out on its own, and
|
||||
// what the production fold concludes. The independence matters — a probe that
|
||||
// only asked the implementation would agree with it by construction.
|
||||
func skipCauseRow(before, after Observation) row {
|
||||
all, byOrder, bySettle := impliedSkipCauses(after)
|
||||
return row{
|
||||
Semantic: "skipped cause: what the picture named, what the facts permit",
|
||||
Authority: "the fan-out's own choice against the durable upstream set",
|
||||
Artifact: "none: the reason text is not recorded",
|
||||
Reconstruction: "declaration order, or earliest released, over the upstreams that did not answer",
|
||||
Before: actualSkipCause(before),
|
||||
After: "candidates " + orNone(all) + " | by-order " + orNone(byOrder) +
|
||||
" | by-settle " + orNone(bySettle) + " | rebuild " + orNone(rebuiltSkipCauses(after)),
|
||||
Verdict: verdictStable,
|
||||
}
|
||||
}
|
||||
|
||||
// rebuiltSkipCauses is what the production fold names, asked the same question
|
||||
// through its own code path.
|
||||
func rebuiltSkipCauses(o Observation) string {
|
||||
children := make([]execgraph.ChildOutcome, 0, len(o.Children.Facts))
|
||||
for _, f := range o.Children.Facts {
|
||||
children = append(children, execgraph.ChildOutcome{
|
||||
Execution: f.ParentToolCallID, Ref: f.Ref, Status: f.Status,
|
||||
})
|
||||
}
|
||||
rebuilt := execgraph.Rebuild(o.Executions, children, nil)
|
||||
var out []string
|
||||
for _, n := range rebuilt.Graph.Nodes {
|
||||
if n.State != agentgraph.StateSkipped {
|
||||
continue
|
||||
}
|
||||
if cause := execgraph.SkipCause(o.Executions, children, n.ID); cause != "" {
|
||||
out = append(out, n.ID+"<-"+cause)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
// contextDeriveRow asks the same question of the delivery edge, now that a
|
||||
// restart can tell which upstreams answered.
|
||||
func contextDeriveRow(before, after Observation) row {
|
||||
actual, implied := contextEdges(before), impliedContextFromDurable(after)
|
||||
verdict := verdictHolds
|
||||
if implied != actual {
|
||||
verdict = verdictViolated
|
||||
}
|
||||
return row{
|
||||
Semantic: "context edges: derived against drawn", Authority: "the journal joined to the store",
|
||||
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
|
||||
Reconstruction: "a started item receives from every upstream that answered",
|
||||
Before: "drawn " + orNone(actual), After: "derived " + orNone(implied), Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
// deriveArmInvalid refuses an arm that did not reach the shape it compares.
|
||||
func deriveArmInvalid(arm string, before Observation) string {
|
||||
if arm == armDeriveAnswered {
|
||||
if len(nodesInState(graphOf(before), agentgraph.StateAdopted)) == 0 {
|
||||
return "no item was adopted, so the arm never had an answered upstream that did not run"
|
||||
}
|
||||
if contextEdges(before) == "" {
|
||||
return "no answer was delivered, so there is no context edge to derive"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if len(nodesInState(graphOf(before), agentgraph.StateSkipped)) == 0 {
|
||||
return "no dependent was skipped; the fan-out ended as " + orNone(terminalSummary(before))
|
||||
}
|
||||
if len(nodesInState(graphOf(before), agentgraph.StateFailed)) < 2 {
|
||||
return "only one upstream ended without an answer, so the cause was never ambiguous"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/agentgraph"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
// The two ephemeral entry points, read against the execution read model's own
|
||||
// authority: the snapshot is what a reader starts from and what a gap restores,
|
||||
// and the stream is a projection of the same facts. A node only the stream has
|
||||
// is therefore not a lighter node but one the authority cannot account for, and
|
||||
// these arms ask whether either entry point produces one.
|
||||
const (
|
||||
armEphemeralTask = "ephemeral-task-authority"
|
||||
armEphemeralSkill = "ephemeral-skill-authority"
|
||||
)
|
||||
|
||||
func ephemeralArm(name string) bool {
|
||||
return name == armEphemeralTask || name == armEphemeralSkill
|
||||
}
|
||||
|
||||
const (
|
||||
ephemeralSentinel = "PROBE-EPHEMERAL"
|
||||
// ephemeralCallID is the call the model makes. Both entry points use it, so
|
||||
// the two arms differ by which tool ran and by nothing else.
|
||||
ephemeralCallID = "probe_ephemeral"
|
||||
)
|
||||
|
||||
// ephemeralCall dispatches the entry point this arm measures. Its child hangs,
|
||||
// because every row here is about a run that is still happening: an ephemeral
|
||||
// execution that has ended owes no reader anything.
|
||||
func ephemeralCall(arm string) []provider.Chunk {
|
||||
name, args := "read_only_task", map[string]any{
|
||||
"prompt": childHang + " ephemeral research", "description": "still executing at death",
|
||||
}
|
||||
if arm == armEphemeralSkill {
|
||||
name, args = "read_only_skill", map[string]any{
|
||||
"name": probeSkillName, "arguments": childHang + " ephemeral research",
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(args)
|
||||
return []provider.Chunk{{
|
||||
Type: provider.ChunkToolCall,
|
||||
ToolCall: &provider.ToolCall{ID: ephemeralCallID, Name: name, Arguments: string(raw)},
|
||||
}}
|
||||
}
|
||||
|
||||
// runEphemeralConstruct drives one ephemeral run and reads three surfaces at the
|
||||
// same instant, while its child is inside the provider: what the stream drew,
|
||||
// what the authority holds, and what a reader is left with once a gap makes it
|
||||
// start from the authority again.
|
||||
func runEphemeralConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
|
||||
go func() { _ = ctrl.Run(ctx, fanOutTurn(turn+1, ephemeralSentinel)) }()
|
||||
if err := waitForEphemeral(prov); err != nil {
|
||||
return err
|
||||
}
|
||||
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
|
||||
obs.Progress[probeChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(childHang)))}
|
||||
// One instant, three readings. Taken here because two of them cannot be
|
||||
// taken later at all: the authority is recomputed per call, and what a gap
|
||||
// leaves is a claim about the moment the gap happens.
|
||||
live, _ := sink.snapshot()
|
||||
authority := ctrl.ExecutionGraph()
|
||||
obs.Progress[probeEphemeralLive] = []string{ephemeralNodes(live)}
|
||||
obs.Progress[probeEphemeralAuthority] = []string{ephemeralNodes(authority.Graph)}
|
||||
obs.Progress[probeEphemeralAfterGap] = []string{ephemeralNodes(afterGap(authority.Graph))}
|
||||
if err := writeObservation(root, obs); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// The three readings one instant produces. They ride the observation because
|
||||
// none of them survives the process, and the arm's whole question is what a
|
||||
// reader could see while the run was happening.
|
||||
const (
|
||||
probeEphemeralLive = "probe:ephemeral-live"
|
||||
probeEphemeralAuthority = "probe:ephemeral-authority"
|
||||
probeEphemeralAfterGap = "probe:ephemeral-after-gap"
|
||||
)
|
||||
|
||||
// afterGap is what a view holds once a dropped stream makes it start from the
|
||||
// authority again: the snapshot, and only the snapshot. A resync replaces the
|
||||
// folded state rather than merging into it — that is what makes the snapshot the
|
||||
// authority — so whatever the fold held and it does not has left the screen.
|
||||
func afterGap(authority agentgraph.Graph) agentgraph.Graph { return authority }
|
||||
|
||||
func waitForEphemeral(prov *scripted) error {
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if prov.fleets.held(childEntered(childHang)) {
|
||||
// Let the picture settle: a reading taken the instant the child
|
||||
// arrives could miss a delta that was already on its way.
|
||||
time.Sleep(time.Second)
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return errUnexpected("an ephemeral child inside the provider", "it never reached one")
|
||||
}
|
||||
|
||||
// ephemeralNodes renders the nodes belonging to this arm's run, worker and group
|
||||
// alike: a group card drawn for work the authority cannot account for is the
|
||||
// same defect as a worker one.
|
||||
func ephemeralNodes(g agentgraph.Graph) string {
|
||||
var out []string
|
||||
for _, n := range g.Nodes {
|
||||
if strings.HasPrefix(n.ID, ephemeralCallID) {
|
||||
out = append(out, n.ID+":"+orDash(string(n.State)))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return orNone(join(out))
|
||||
}
|
||||
|
||||
func ephemeralRows(arm string, before, after Observation) []row {
|
||||
return []row{
|
||||
ephemeralLiveRow(before),
|
||||
ephemeralAuthorityRow(before),
|
||||
ephemeralGapRow(before),
|
||||
ephemeralRestartRow(before, after),
|
||||
ephemeralDurableRow(before, after),
|
||||
ephemeralContractRow(arm, before),
|
||||
}
|
||||
}
|
||||
|
||||
const authorityName = "Controller.ExecutionGraph (the snapshot a reader starts from)"
|
||||
|
||||
func ephemeralLiveRow(before Observation) row {
|
||||
drawn := firstProgress(before, probeEphemeralLive)
|
||||
return row{
|
||||
Semantic: "what the stream drew while it ran", Authority: liveAuthority,
|
||||
Artifact: "none observed", Reconstruction: "in-process only",
|
||||
Before: drawn, After: "—", Verdict: presenceVerdict(drawn != "none"),
|
||||
}
|
||||
}
|
||||
|
||||
func ephemeralAuthorityRow(before Observation) row {
|
||||
held := firstProgress(before, probeEphemeralAuthority)
|
||||
return row{
|
||||
Semantic: "what the authority held at the same instant", Authority: authorityName,
|
||||
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
|
||||
Reconstruction: "execgraph.Rebuild over durable facts, recomputed on demand",
|
||||
Before: held, After: "—", Verdict: presenceVerdict(held != "none"),
|
||||
}
|
||||
}
|
||||
|
||||
// ephemeralGapRow is the one that decides. A reader that loses the stream starts
|
||||
// from the authority again, so whatever the fold held and the authority does not
|
||||
// leaves the screen — while the child it named is still holding a slot.
|
||||
func ephemeralGapRow(before Observation) row {
|
||||
left := firstProgress(before, probeEphemeralAfterGap)
|
||||
return row{
|
||||
Semantic: "what a reader is left with after a gap", Authority: authorityName,
|
||||
Artifact: "none: the fold is replaced, never merged",
|
||||
Reconstruction: "a resync starts from the authority",
|
||||
Before: left, After: "—", Verdict: presenceVerdict(left != "none"),
|
||||
}
|
||||
}
|
||||
|
||||
func ephemeralRestartRow(before, after Observation) row {
|
||||
return row{
|
||||
Semantic: "what a restart can say about it", Authority: rebuildAuthority,
|
||||
Artifact: "<stem>.execution.jsonl",
|
||||
Reconstruction: "execgraph.Rebuild in the next process",
|
||||
Before: "executing inside the provider",
|
||||
After: ephemeralNodes(rebuiltGraph(after).Graph),
|
||||
Verdict: presenceVerdict(ephemeralNodes(rebuiltGraph(after).Graph) != "none"),
|
||||
}
|
||||
}
|
||||
|
||||
// ephemeralDurableRow is the contract both entry points already promise. It is
|
||||
// read rather than assumed, because a convergence that made one of them visible
|
||||
// by recording it would satisfy the rows above and break this one.
|
||||
func ephemeralDurableRow(before, after Observation) row {
|
||||
return presenceRow("journal and store", crossAuthority,
|
||||
"<stem>.execution.jsonl, subagents/<ref>.meta.json",
|
||||
"what an ephemeral entry point promises to leave: nothing",
|
||||
ephemeralDurable(before), ephemeralDurable(after))
|
||||
}
|
||||
|
||||
func ephemeralDurable(o Observation) []string {
|
||||
var out []string
|
||||
for _, e := range o.Executions {
|
||||
if strings.HasPrefix(e.ID, ephemeralCallID) {
|
||||
out = append(out, "journal:"+e.ID)
|
||||
}
|
||||
}
|
||||
for _, f := range o.Children.Facts {
|
||||
if strings.HasPrefix(f.ParentToolCallID, ephemeralCallID) {
|
||||
out = append(out, "store:"+f.ParentToolCallID+"="+f.Status)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ephemeralContractRow states the authority contract as a conjunction. A node
|
||||
// the stream drew, an authority that cannot account for it, and a child still
|
||||
// executing: that is not a lighter kind of node, it is one that leaves the
|
||||
// screen at the next gap while the work it named goes on holding a slot.
|
||||
func ephemeralContractRow(arm string, before Observation) row {
|
||||
live := firstProgress(before, probeEphemeralLive) != "none"
|
||||
authority := firstProgress(before, probeEphemeralAuthority) != "none"
|
||||
running := firstProgress(before, probeChildEntered) == "true"
|
||||
answer, verdict := "no node was drawn: nothing claims what the authority cannot hold", verdictHolds
|
||||
switch {
|
||||
case !running:
|
||||
answer, verdict = "the child never reached the provider", verdictNotMeasured
|
||||
case live && !authority:
|
||||
answer, verdict = "drawn by the stream, absent from the authority, and still executing", verdictViolated
|
||||
case live && authority:
|
||||
answer, verdict = "drawn, and the authority accounts for it", verdictHolds
|
||||
}
|
||||
return row{
|
||||
Semantic: "a node the authority cannot account for", Authority: crossAuthority,
|
||||
Artifact: "none, which is the point",
|
||||
Reconstruction: "the stream and the snapshot, read at one instant",
|
||||
Before: answer, After: "—", Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
// ephemeralArmInvalid reports the premise this arm could not establish.
|
||||
func ephemeralArmInvalid(before Observation) string {
|
||||
if firstProgress(before, probeChildEntered) != "true" {
|
||||
return "the ephemeral child never reached the provider, so nothing was executing at the reading"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,641 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/agentgraph"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
// The three fan-out arms. Each dies at a different point in one fleet's life,
|
||||
// because "what survives" has no single answer: an item that finished, one
|
||||
// still executing, and one the graph knows about without ever running are
|
||||
// carried by different host state, and only separating them says which.
|
||||
const (
|
||||
// armGraphCompleted lets the fleet finish and the turn close before the
|
||||
// process exits. Nothing is interrupted, so a loss here is not about
|
||||
// interruption at all — it says execution provenance itself is ephemeral.
|
||||
armGraphCompleted = "graph-completed"
|
||||
// armGraphRunning kills the process with a child mid-execution. It asks the
|
||||
// one question a completed fleet cannot: what does the new process believe
|
||||
// about work whose owner is gone?
|
||||
armGraphRunning = "graph-running"
|
||||
// armGraphMixed carries every state at once — completed, running, adopted,
|
||||
// failed, skipped — plus a read grant, a write grant, and a dependency edge.
|
||||
// It is the density arm: one death, every semantic the graph claims to hold.
|
||||
armGraphMixed = "graph-mixed"
|
||||
)
|
||||
|
||||
func graphArm(name string) bool {
|
||||
return name == armGraphCompleted || name == armGraphRunning || name == armGraphMixed
|
||||
}
|
||||
|
||||
// Child sentinels. A child's prompt decides what its run does, and the probe
|
||||
// reads the prompt off the request's last user message rather than scanning the
|
||||
// whole conversation: the parent's own transcript quotes every fleet argument,
|
||||
// so a scan would make the parent answer as its own children.
|
||||
const (
|
||||
childDone = "PROBE-CHILD-DONE"
|
||||
childHang = "PROBE-CHILD-HANG"
|
||||
childFail = "PROBE-CHILD-FAIL"
|
||||
)
|
||||
|
||||
// Parent sentinels, one per fleet the arm dispatches.
|
||||
const (
|
||||
fleetWarmSentinel = "PROBE-FLEET-WARM"
|
||||
fleetMixedSentinel = "PROBE-FLEET-MIXED"
|
||||
fleetPairSentinel = "PROBE-FLEET-PAIR"
|
||||
// The scheduler arms. Holder fills a ceiling and never ends; refused asks
|
||||
// for admission the scheduler must deny. Two sentinels rather than one
|
||||
// because the claim and transition arms need the holder to be already
|
||||
// admitted before the second fleet is dispatched.
|
||||
fleetHolderSentinel = "PROBE-FLEET-HOLDER"
|
||||
fleetRefusedSentinel = "PROBE-FLEET-REFUSED"
|
||||
// The terminal arms. Their fan-outs run to completion, because a skip is
|
||||
// published only in the closing delta; the turn is then held open so the
|
||||
// process still dies mid-turn.
|
||||
fleetTerminalSentinel = "PROBE-FLEET-TERMINAL"
|
||||
// Named so it is not contained in PROBE-PARALLEL-IDENTITY. Sentinels are
|
||||
// matched by substring, so one inside another dispatches a fan-out its arm
|
||||
// never asked for — this pair silently ran cancelParallel in the identity
|
||||
// arm, whose rows then read a scenario nobody wrote.
|
||||
parallelSentinel = "PROBE-PARALLEL-CANCEL"
|
||||
fleetOutcomesSentinel = "PROBE-FLEET-OUTCOMES"
|
||||
fleetDeriveSentinel = "PROBE-FLEET-DERIVE"
|
||||
fleetIdentitySentinel = "PROBE-FLEET-IDENTITY"
|
||||
parallelIdentity = "PROBE-PARALLEL-IDENTITY"
|
||||
// The active-store arm. Its fan-out shares one ceiling with a lone
|
||||
// delegation, so the four populations it needs are reached in one death.
|
||||
fleetActiveSentinel = "PROBE-FLEET-ACTIVE"
|
||||
)
|
||||
|
||||
// childHold is a child that reports holding its slot before it blocks, so a
|
||||
// later fleet can be dispatched knowing the ceiling is already occupied. Timing
|
||||
// it any other way races the scheduler.
|
||||
const (
|
||||
childHold = "PROBE-CHILD-HOLD"
|
||||
// childRelease finishes only when the arm frees it, which is how capacity
|
||||
// is returned while a refusal is still standing.
|
||||
childRelease = "PROBE-CHILD-RELEASE"
|
||||
// childFailLate fails only once the arm frees it, so two failures in one
|
||||
// fan-out have a decided order instead of a raced one. Its wording shares no
|
||||
// prefix with childFail: the sentinels are matched by substring, so a name
|
||||
// containing another's makes the shorter one win and the delay disappear.
|
||||
childFailLate = "PROBE-CHILD-DELAYEDFAIL"
|
||||
)
|
||||
|
||||
// probeClaimPath is the write path the claim arm overlaps. The refused writer
|
||||
// asks for a path inside it, which is a conflict no ceiling change releases.
|
||||
const (
|
||||
probeClaimPath = "probe-claim"
|
||||
probeClaimInner = "probe-claim/inner"
|
||||
)
|
||||
|
||||
// subagentRefPattern finds the references an earlier fleet's aggregate reported.
|
||||
// The mixed arm adopts one of them, which is the only way to reach StateAdopted
|
||||
// through the tool's own contract rather than by constructing the state.
|
||||
var subagentRefPattern = regexp.MustCompile(`sa_[A-Za-z0-9_-]+`)
|
||||
|
||||
// childScript answers one delegated run. Hanging is expressed as a context read
|
||||
// so the child holds its scheduler slot until the process dies — a sleep long
|
||||
// enough to look the same would still be a race against the probe's deadline.
|
||||
func (s *scripted) childScript(ctx context.Context, sentinel string) []provider.Chunk {
|
||||
// Reaching here is the child's own proof that it started, and returning is
|
||||
// its proof that something let it go: an arm asking whether a cancellation
|
||||
// reached a running child has no other way to know.
|
||||
s.fleets.first(childEntered(sentinel))
|
||||
defer s.fleets.first(childLeft(sentinel))
|
||||
switch sentinel {
|
||||
case childHold:
|
||||
// Reaching a provider call means the slot is already held, which is the
|
||||
// only moment a later fleet can be dispatched against a full ceiling
|
||||
// without racing it.
|
||||
s.holding.Do(func() { close(s.held) })
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
case childRelease:
|
||||
<-s.release
|
||||
return append(text("Child released."), done())
|
||||
case childFailLate:
|
||||
<-s.release
|
||||
return []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf("probe child failed on release")}}
|
||||
case childHang:
|
||||
<-ctx.Done()
|
||||
// The context closed, and this is the only place that can say so: a
|
||||
// caller that stopped waiting leaves the same absence behind.
|
||||
s.fleets.first(childCtxDone(sentinel))
|
||||
return nil
|
||||
case childFail:
|
||||
return []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf("probe child failed on purpose")}}
|
||||
default:
|
||||
return append(text("Child answered."), done())
|
||||
}
|
||||
}
|
||||
|
||||
// askedInPrompt reports whether this request's own user turns carry a sentinel.
|
||||
// The user side only: a fleet's arguments live in an assistant message, so a
|
||||
// whole-conversation scan makes a parent answer as one of its own children.
|
||||
// Every user turn, not the last: the host appends its own notes after the
|
||||
// prompt, and the last user message in a request is frequently one of those.
|
||||
func askedInPrompt(req provider.Request, sentinel string) bool {
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == provider.RoleUser && strings.Contains(m.Content, sentinel) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// childSentinel reports which child script this request is, empty when the
|
||||
// request is a parent turn.
|
||||
func childSentinel(req provider.Request) string {
|
||||
for _, sentinel := range []string{childDone, childHang, childFail, childHold, childRelease, childFailLate} {
|
||||
if askedInPrompt(req, sentinel) {
|
||||
return sentinel
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fanOutTurnRecorded reports whether the dispatching turn is in these messages.
|
||||
// The probe reads its own sentinel rather than counting rows: a turn appended
|
||||
// at some later point would still be there, and only the prompt says whether
|
||||
// this is the turn the fan-out was opened in.
|
||||
func fanOutTurnRecorded(msgs []provider.Message) bool {
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(m.Content, fleetPairSentinel) || strings.Contains(m.Content, fleetMixedSentinel) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fleetCall renders one fleet dispatch. Nothing closes the round it opens: all
|
||||
// three arms die inside the turn, so the host's final-answer readiness check
|
||||
// never runs and the arms measure a process boundary rather than a task list.
|
||||
func fleetCall(id string, tasks []map[string]any) []provider.Chunk {
|
||||
args, _ := json.Marshal(map[string]any{"tasks": tasks})
|
||||
return []provider.Chunk{{
|
||||
Type: provider.ChunkToolCall,
|
||||
ToolCall: &provider.ToolCall{ID: id, Name: "fleet", Arguments: string(args)},
|
||||
}}
|
||||
}
|
||||
|
||||
// warmFleet finishes two children so a later fleet has a completed reference to
|
||||
// adopt. Both are read-only: nothing about the warm-up is under measurement.
|
||||
func warmFleet() []provider.Chunk {
|
||||
return fleetCall("probe_fleet_warm", []map[string]any{
|
||||
{"id": "w1", "prompt": childDone + " warm one", "description": "warm one", "read_only": true},
|
||||
{"id": "w2", "prompt": childDone + " warm two", "description": "warm two", "read_only": true},
|
||||
})
|
||||
}
|
||||
|
||||
// pairFleet is the two-item shape both single-state arms use. The running arm
|
||||
// dies with the second item still executing; the completed arm lets both
|
||||
// finish, so the two differ by when the process exits and nothing else.
|
||||
func pairFleet(second string) []provider.Chunk {
|
||||
return fleetCall("probe_fleet_pair", []map[string]any{
|
||||
{"id": "p1", "prompt": childDone + " pair one", "description": "pair one", "read_only": true},
|
||||
{"id": "p2", "prompt": second + " pair two", "description": "pair two", "read_only": true},
|
||||
})
|
||||
}
|
||||
|
||||
// mixedFleet is the density arm's dispatch: one item per terminal the graph can
|
||||
// record, plus one still running, and the two grants told apart. The write item
|
||||
// declares a path so preflight admits it beside the readers; it never writes,
|
||||
// because the grant is the envelope under measurement, not the effect.
|
||||
func mixedFleet(adoptRef string) []provider.Chunk {
|
||||
return fleetCall("probe_fleet_mixed", []map[string]any{
|
||||
// Model and effort are named so the identity row measures something: two
|
||||
// nodes agreeing that a field is empty proves nothing about recovering it.
|
||||
{"id": "m1", "prompt": childDone + " mixed one", "description": "completes", "read_only": true,
|
||||
"model": probeModelRef, "effort": "high"},
|
||||
{"id": "m2", "prompt": childFail + " mixed two", "description": "fails", "read_only": true},
|
||||
{"id": "m3", "prompt": childDone + " mixed three", "description": "skipped by m2", "read_only": true, "depends_on": []string{"m2"}},
|
||||
{"id": "m4", "adopt_ref": adoptRef, "description": "adopted"},
|
||||
{"id": "m5", "prompt": childHang + " mixed five", "description": "running at death", "write_paths": []string{"probe-fanout-out"}},
|
||||
})
|
||||
}
|
||||
|
||||
// opens reports whether this round is the one that dispatches that fleet.
|
||||
func (s *scripted) opens(req provider.Request, sentinel string) bool {
|
||||
return askedInPrompt(req, sentinel) && s.fleets.first(sentinel)
|
||||
}
|
||||
|
||||
// opensRun is the same for the fan-out an arm is built around: the terminal and
|
||||
// derive arms hold the turn open once theirs has gone out.
|
||||
func (s *scripted) opensRun(req provider.Request, sentinel string) bool {
|
||||
if !s.opens(req, sentinel) {
|
||||
return false
|
||||
}
|
||||
s.opened.Store(true)
|
||||
return true
|
||||
}
|
||||
|
||||
// fanOut answers a parent turn that opens a fleet, at most once per sentinel.
|
||||
// The sentinel is read off the current user turn, and the latch is what stops
|
||||
// the round that receives the aggregate from dispatching the same fleet again.
|
||||
func (s *scripted) fanOut(req provider.Request) ([]provider.Chunk, bool) {
|
||||
switch {
|
||||
case s.opens(req, fleetWarmSentinel):
|
||||
return append(warmFleet(), done()), true
|
||||
case s.opens(req, fleetPairSentinel):
|
||||
if s.arm == armWaitSlots {
|
||||
return append(slotsFleet(), done()), true
|
||||
}
|
||||
second := childDone
|
||||
if s.arm == armGraphRunning {
|
||||
second = childHang
|
||||
}
|
||||
return append(pairFleet(second), done()), true
|
||||
case s.opens(req, fleetSettledSentinel):
|
||||
return append(settledFleet(), done()), true
|
||||
case s.opens(req, fleetLiveSentinel):
|
||||
return append(liveFleet(), done()), true
|
||||
case s.opens(req, fleetMixedSentinel):
|
||||
if s.arm == armTerminalAdopted {
|
||||
return append(adoptOnlyFleet(adoptableRef(req)), done()), true
|
||||
}
|
||||
if s.arm == armUIGraphMixed {
|
||||
return append(uiMixedFleet(adoptableRef(req)), done()), true
|
||||
}
|
||||
return append(mixedFleet(adoptableRef(req)), done()), true
|
||||
case s.opensRun(req, fleetIdentitySentinel):
|
||||
return append(identityFleet(adoptableRef(req)), done()), true
|
||||
case s.opens(req, parallelIdentity):
|
||||
return append(identityParallel(), done()), true
|
||||
case s.opensRun(req, fleetDeriveSentinel):
|
||||
return append(deriveFleet(s.arm, adoptableRef(req)), done()), true
|
||||
case s.opensRun(req, fleetOutcomesSentinel):
|
||||
return append(outcomesFleet(), done()), true
|
||||
case s.opensRun(req, fleetTerminalSentinel):
|
||||
return append(terminalFleet(s.arm), done()), true
|
||||
case s.opensRun(req, parallelSentinel):
|
||||
return append(cancelParallel(), done()), true
|
||||
case s.fleets.held(taskSentinel) && s.opens(req, fleetActiveSentinel):
|
||||
// The delegation goes first and its own child says when the slot is
|
||||
// taken. Dispatching before that lets the fan-out take the whole
|
||||
// ceiling, and the arm loses the entry point it exists to compare.
|
||||
<-s.held
|
||||
return append(activeStoreFleet(), done()), true
|
||||
case s.opens(req, fleetHolderSentinel):
|
||||
return append(holderFleet(s.arm), done()), true
|
||||
case s.opens(req, fleetRefusedSentinel):
|
||||
// The holder's own child says when its ceiling is occupied. Dispatching
|
||||
// before that lets the refused item win the race and start.
|
||||
<-s.held
|
||||
return append(refusedFleet(s.arm), done()), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// holderFleet occupies the ceiling this arm measures. The slots arm needs no
|
||||
// holder — its refused item is in the same fleet as the runs that fill the
|
||||
// ceiling — so only the arms that need one dispatch it in the background.
|
||||
func holderFleet(arm string) []provider.Chunk {
|
||||
tasks := []map[string]any{
|
||||
{"id": "h1", "prompt": childHold + " holder", "description": "holds the ceiling", "write_paths": []string{probeClaimPath}},
|
||||
{"id": "h2", "prompt": childHang + " filler", "description": "filler", "read_only": true},
|
||||
}
|
||||
if arm == armWaitTransition {
|
||||
// The second run is what the arm later releases: freeing total capacity
|
||||
// while the writer ceiling stays full is how the blocker changes.
|
||||
tasks[1] = map[string]any{"id": "h2", "prompt": childRelease + " releasable", "description": "released mid-wait", "read_only": true}
|
||||
}
|
||||
if bothHoldersReport(arm) {
|
||||
tasks[1] = map[string]any{"id": "h2", "prompt": childHold + " second holder", "description": "holds the ceiling", "read_only": true}
|
||||
}
|
||||
return backgroundFleetCall("probe_fleet_holder", tasks)
|
||||
}
|
||||
|
||||
// bothHoldersReport names the arms that leave one slot free. Either holder may
|
||||
// win it, so both report holding, and the refused delegation's own child is then
|
||||
// the only arrival that means anything — a filler that hung under the same
|
||||
// sentinel would be read as the subject having started.
|
||||
func bothHoldersReport(arm string) bool {
|
||||
return queuedTaskArm(arm) || queuedSkillArm(arm) || queuedHostArm(arm)
|
||||
}
|
||||
|
||||
// refusedFleet asks for admission the scheduler must deny, with every check
|
||||
// above this arm's cause already cleared.
|
||||
func refusedFleet(arm string) []provider.Chunk {
|
||||
refused := map[string]any{"id": "r1", "prompt": childHang + " refused", "description": "refused admission"}
|
||||
switch arm {
|
||||
case armWaitWriters:
|
||||
// Disjoint from the holder's path, so only the writer ceiling can
|
||||
// refuse it.
|
||||
refused["write_paths"] = []string{"probe-writers-own"}
|
||||
case armWaitClaim:
|
||||
refused["write_paths"] = []string{probeClaimInner}
|
||||
default:
|
||||
refused["write_paths"] = []string{"probe-transition-own"}
|
||||
}
|
||||
return fleetCall("probe_fleet_refused", []map[string]any{
|
||||
refused,
|
||||
{"id": "r2", "prompt": childHang + " sibling", "description": "sibling", "read_only": true},
|
||||
})
|
||||
}
|
||||
|
||||
// terminalFleet runs to completion holding the disposition its arm measures.
|
||||
// The skipped arm cuts a branch with a failure; the context arm delivers a real
|
||||
// answer across an ordering edge, which is the only way a context edge appears.
|
||||
func terminalFleet(arm string) []provider.Chunk {
|
||||
upstream := map[string]any{"id": "up", "prompt": childDone + " upstream", "description": "upstream", "read_only": true}
|
||||
if arm == armTerminalSkippedDep {
|
||||
upstream["prompt"] = childFail + " upstream"
|
||||
}
|
||||
return fleetCall("probe_fleet_terminal", []map[string]any{
|
||||
upstream,
|
||||
{"id": "down", "prompt": childDone + " downstream", "description": "downstream", "read_only": true, "depends_on": []string{"up"}},
|
||||
})
|
||||
}
|
||||
|
||||
// cancelParallel opens more items than the session admits at once, so the arm
|
||||
// can interrupt the group while one has started and another has not. That split
|
||||
// is what parallel_tasks reports as cancelled against skipped.
|
||||
func cancelParallel() []provider.Chunk {
|
||||
args, _ := json.Marshal(map[string]any{"tasks": []map[string]any{
|
||||
{"prompt": childHang + " first", "description": "started"},
|
||||
{"prompt": childHang + " second", "description": "never started"},
|
||||
{"prompt": childHang + " third", "description": "never started"},
|
||||
}})
|
||||
return []provider.Chunk{{
|
||||
Type: provider.ChunkToolCall,
|
||||
ToolCall: &provider.ToolCall{ID: "probe_parallel", Name: "parallel_tasks", Arguments: string(args)},
|
||||
}}
|
||||
}
|
||||
|
||||
// adoptOnlyFleet runs to completion with one item adopted and one that ran.
|
||||
// Nothing hangs: the arm compares two settled items whose only difference is
|
||||
// that one executed, which a fan-out still in flight could not show.
|
||||
func adoptOnlyFleet(adoptRef string) []provider.Chunk {
|
||||
return fleetCall("probe_fleet_terminal", []map[string]any{
|
||||
{"id": "a1", "adopt_ref": adoptRef, "description": "adopted"},
|
||||
{"id": "a2", "prompt": childDone + " ran", "description": "ran", "read_only": true},
|
||||
})
|
||||
}
|
||||
|
||||
// outcomesFleet reaches all three executed terminals in one group: one item
|
||||
// completes, one fails, and two are still running when the arm interrupts. The
|
||||
// store's record of each is then compared under identical conditions.
|
||||
func outcomesFleet() []provider.Chunk {
|
||||
return fleetCall("probe_fleet_outcomes", []map[string]any{
|
||||
{"id": "o1", "prompt": childDone + " completes", "description": "completes", "read_only": true},
|
||||
{"id": "o2", "prompt": childFail + " fails", "description": "fails", "read_only": true},
|
||||
{"id": "o3", "prompt": childHang + " cancelled", "description": "cancelled", "read_only": true},
|
||||
{"id": "o4", "prompt": childHang + " cancelled too", "description": "cancelled too", "read_only": true},
|
||||
})
|
||||
}
|
||||
|
||||
// identityFleet names a worker identity four ways: not at all, by model, by
|
||||
// effort, and not at all because nothing runs. What the graph then shows for
|
||||
// each is what says which layer of the resolution it is reporting.
|
||||
func identityFleet(adoptRef string) []provider.Chunk {
|
||||
return fleetCall("probe_fleet_identity", []map[string]any{
|
||||
{"id": "i1", "prompt": childDone + " default", "description": "inherits", "read_only": true},
|
||||
{"id": "i2", "prompt": childDone + " override", "description": "names a model", "read_only": true,
|
||||
"model": probeAltModelRef, "effort": "high"},
|
||||
{"id": "i3", "prompt": childDone + " effort only", "description": "names an effort", "read_only": true,
|
||||
"effort": "low"},
|
||||
{"id": "i4", "adopt_ref": adoptRef, "description": "runs nothing"},
|
||||
})
|
||||
}
|
||||
|
||||
// identityParallel asks the same of the other producer, which resolves through
|
||||
// a shorter chain: it has no profile layer to consult.
|
||||
func identityParallel() []provider.Chunk {
|
||||
args, _ := json.Marshal(map[string]any{"tasks": []map[string]any{
|
||||
{"prompt": childDone + " p default", "description": "inherits"},
|
||||
{"prompt": childDone + " p override", "description": "names a model",
|
||||
"model": probeAltModelRef, "effort": "high"},
|
||||
}})
|
||||
return []provider.Chunk{{
|
||||
Type: provider.ChunkToolCall,
|
||||
ToolCall: &provider.ToolCall{ID: "probe_parallel_identity", Name: "parallel_tasks", Arguments: string(args)},
|
||||
}}
|
||||
}
|
||||
|
||||
// deriveFleet orders one dependent behind two upstreams. The skip arms end both
|
||||
// upstreams without an answer, one immediately and one on release, so which of
|
||||
// them the fan-out names is decided by the order rather than by a race; the
|
||||
// answered arm gives the dependent one completed and one adopted upstream, so
|
||||
// it must run and both deliveries must be accounted for.
|
||||
func deriveFleet(arm, adoptRef string) []provider.Chunk {
|
||||
first, second := childFail+" first", childFailLate+" second"
|
||||
if arm == armDeriveSkipFlip {
|
||||
first, second = childFailLate+" first", childFail+" second"
|
||||
}
|
||||
up := []map[string]any{
|
||||
{"id": "a", "prompt": first, "description": "upstream a", "read_only": true},
|
||||
{"id": "b", "prompt": second, "description": "upstream b", "read_only": true},
|
||||
}
|
||||
if arm == armDeriveAnswered {
|
||||
up = []map[string]any{
|
||||
{"id": "a", "prompt": childDone + " first", "description": "upstream a", "read_only": true},
|
||||
{"id": "b", "adopt_ref": adoptRef, "description": "upstream b"},
|
||||
}
|
||||
}
|
||||
return fleetCall("probe_fleet_derive", append(up, map[string]any{
|
||||
"id": "c", "prompt": childDone + " dependent", "description": "dependent",
|
||||
"read_only": true, "depends_on": []string{"a", "b"},
|
||||
}))
|
||||
}
|
||||
|
||||
// activeStoreFleet reaches three fan-out populations under one ceiling. The
|
||||
// first item settles and frees the slot the other two then contend for, so one
|
||||
// executes and one is refused — which of them is the scheduler's to decide, and
|
||||
// the arm reads the populations off the journal rather than naming them here.
|
||||
func activeStoreFleet() []provider.Chunk {
|
||||
return fleetCall(activeFleetCallID, []map[string]any{
|
||||
{"id": "a1", "prompt": childDone + " settles", "description": "settles", "read_only": true},
|
||||
{"id": "a2", "prompt": childHang + " holds", "description": "contends for the freed slot",
|
||||
"read_only": true, "depends_on": []string{"a1"}},
|
||||
{"id": "a3", "prompt": childHang + " contends", "description": "contends for the freed slot",
|
||||
"read_only": true, "depends_on": []string{"a1"}},
|
||||
})
|
||||
}
|
||||
|
||||
// slotsFleet fills the total ceiling and asks for one more, all read-only: a
|
||||
// reader clears every writer check by not being a writer.
|
||||
func slotsFleet() []provider.Chunk {
|
||||
return fleetCall("probe_fleet_refused", []map[string]any{
|
||||
{"id": "s1", "prompt": childHang + " one", "description": "fills a slot", "read_only": true},
|
||||
{"id": "s2", "prompt": childHang + " two", "description": "fills a slot", "read_only": true},
|
||||
{"id": "s3", "prompt": childHang + " three", "description": "refused admission", "read_only": true},
|
||||
})
|
||||
}
|
||||
|
||||
// backgroundFleetCall dispatches a fleet that returns a job id at once. The
|
||||
// holder has to keep holding while a later fleet is dispatched, which a
|
||||
// foreground call cannot do: it does not return until every item has settled.
|
||||
func backgroundFleetCall(id string, tasks []map[string]any) []provider.Chunk {
|
||||
args, _ := json.Marshal(map[string]any{"tasks": tasks, "run_in_background": true})
|
||||
return []provider.Chunk{{
|
||||
Type: provider.ChunkToolCall,
|
||||
ToolCall: &provider.ToolCall{ID: id, Name: "fleet", Arguments: string(args)},
|
||||
}}
|
||||
}
|
||||
|
||||
// adoptableRef picks a reference the warm-up fleet reported. It reads the
|
||||
// conversation the model reads: the aggregate names each child's reference, and
|
||||
// an arm that cannot find one has not established its premise.
|
||||
func adoptableRef(req provider.Request) string {
|
||||
for _, m := range slices.Backward(req.Messages) {
|
||||
if found := subagentRefPattern.FindAllString(m.Content, -1); len(found) > 0 {
|
||||
return found[len(found)-1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fanOutTurn is the prompt that opens one fleet.
|
||||
func fanOutTurn(n int, sentinel string) string {
|
||||
return fmt.Sprintf("%s %s: dispatch the fan-out.", marker(n), sentinel)
|
||||
}
|
||||
|
||||
// fanOutSentinels is what this arm's one prompt carries. The mixed arm names
|
||||
// both of its fleets in the same turn: the warm-up has to finish before a
|
||||
// reference can be adopted, and a fleet's aggregate returns into the same turn
|
||||
// that dispatched it, so the second round dispatches the mixed fleet without
|
||||
// the turn ever closing.
|
||||
func fanOutSentinels(arm string) string {
|
||||
if arm == armUIGraphMixed {
|
||||
return fleetWarmSentinel + " " + fleetSettledSentinel + " " + fleetMixedSentinel
|
||||
}
|
||||
if arm == armGraphMixed {
|
||||
return fleetWarmSentinel + " " + fleetMixedSentinel
|
||||
}
|
||||
return fleetPairSentinel
|
||||
}
|
||||
|
||||
// runFanOutConstruct drives the arm's fan-out and exits while the turn is still
|
||||
// open. Every arm dies mid-turn, including the completed one: what separates
|
||||
// them is which states the graph holds at that moment, and a clean shutdown
|
||||
// would cancel a running child and settle its node — the one thing a process
|
||||
// that dies mid-fan-out does not do.
|
||||
func runFanOutConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, turn int) error {
|
||||
go func() { _ = ctrl.Run(ctx, fanOutTurn(turn+1, fanOutSentinels(arm))) }()
|
||||
if err := waitForFanOut(sink, arm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeObservation(root, capture("construct", arm, bootSystem, ctrl, sink, root)); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForFanOut blocks until the graph shows the shape this arm was built to
|
||||
// die with. Waiting on the shape rather than on a duration is what keeps the
|
||||
// arm honest: a timer that fired early would report a smaller fan-out as if
|
||||
// that were what the restart inherited.
|
||||
func waitForFanOut(sink *graphSink, arm string) error {
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
graph, _ := sink.snapshot()
|
||||
if fanOutReady(graph, arm) {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
graph, _ := sink.snapshot()
|
||||
return errUnexpected("a fan-out holding "+wantedText(arm), nodeStates(graph))
|
||||
}
|
||||
|
||||
// fanOutReady is the arm's premise expressed as a graph shape. The completed
|
||||
// arm waits for every worker to settle, which is the opposite condition from
|
||||
// the other two rather than a weaker version of it.
|
||||
func fanOutReady(g agentgraph.Graph, arm string) bool {
|
||||
if arm == armGraphCompleted {
|
||||
return allWorkersSettled(g)
|
||||
}
|
||||
return holdsAll(g, wantedStates(arm))
|
||||
}
|
||||
|
||||
// wantedStates are the states a mid-flight arm must hold at once. Skipped is
|
||||
// not among them and cannot be: a fan-out publishes a skip only in its closing
|
||||
// outcome delta, so nothing reads skipped until the group has ended and nothing
|
||||
// is running. An item whose dependency already failed sits at pending until
|
||||
// then, which is what the mixed arm dies holding.
|
||||
func wantedStates(arm string) []agentgraph.NodeState {
|
||||
want := []agentgraph.NodeState{agentgraph.StateCompleted, agentgraph.StateRunning}
|
||||
if arm == armGraphMixed || arm == armUIGraphMixed {
|
||||
want = append(want, agentgraph.StateFailed, agentgraph.StateAdopted)
|
||||
}
|
||||
// The UI arm dies holding a settled fan-out beside a live one, so it also
|
||||
// reaches the two states the mixed arm cannot: a skip, which is published
|
||||
// only when a group closes, and an item a ceiling never admitted.
|
||||
if arm == armUIGraphMixed {
|
||||
want = append(want, agentgraph.StateSkipped, agentgraph.StatePending, agentgraph.StateQueued)
|
||||
}
|
||||
return want
|
||||
}
|
||||
|
||||
func wantedText(arm string) string {
|
||||
if arm == armGraphCompleted {
|
||||
return "every worker settled"
|
||||
}
|
||||
return statesText(wantedStates(arm))
|
||||
}
|
||||
|
||||
// allWorkersSettled reports whether every worker reached a terminal state. An
|
||||
// empty state is not terminal here even though NodeState.Terminal calls it one:
|
||||
// a node declared before it ran carries no state at all, and reading that as
|
||||
// settled would let the arm die before the fan-out started.
|
||||
func allWorkersSettled(g agentgraph.Graph) bool {
|
||||
seen := false
|
||||
for _, n := range g.Nodes {
|
||||
if n.Kind != agentgraph.KindWorker {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
if n.State == "" || !n.State.Terminal() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
// holdsAll reports whether the graph shows at least one worker in each state.
|
||||
// Group nodes are excluded: a group is the call that owns the fan-out, and its
|
||||
// own running state would satisfy the running requirement without any child
|
||||
// being in flight.
|
||||
func holdsAll(g agentgraph.Graph, want []agentgraph.NodeState) bool {
|
||||
for _, state := range want {
|
||||
found := false
|
||||
for _, n := range g.Nodes {
|
||||
if n.Kind == agentgraph.KindWorker && n.State == state {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func statesText(states []agentgraph.NodeState) string {
|
||||
out := make([]string, 0, len(states))
|
||||
for _, s := range states {
|
||||
out = append(out, string(s))
|
||||
}
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func nodeStates(g agentgraph.Graph) string {
|
||||
out := make([]string, 0, len(g.Nodes))
|
||||
for _, n := range g.Nodes {
|
||||
out = append(out, n.ID+":"+string(n.State))
|
||||
}
|
||||
return orNone(join(out))
|
||||
}
|
||||
@@ -1,646 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/agentgraph"
|
||||
"reasonix/internal/runtime/delegation"
|
||||
"reasonix/internal/state/execgraph"
|
||||
"reasonix/internal/state/execjournal"
|
||||
)
|
||||
|
||||
// How a run graph can stand after the process that built it exits. Three are
|
||||
// designs a host may hold; two are defects, and not the same defect. Losing the
|
||||
// graph costs a reader an explanation; a node still drawn as running with no
|
||||
// owner costs them the truth — reviving actionable state is more dangerous than
|
||||
// dropping it, and a running node is actionable state.
|
||||
const (
|
||||
graphExact = "reconstructed-exact" // nodes, edges, grants, waits, states all back
|
||||
graphInterrupted = "interrupted-explicit" // the fan-out is legible and the live child is marked cut
|
||||
graphLossy = "reconstructed-lossy" // it is back, minus semantics nothing can re-derive
|
||||
graphLostSilent = "LOST-SILENT" // gone, with no provenance that it ever existed
|
||||
graphGhost = "GHOST-RUNNING" // still says running, with no live owner anywhere
|
||||
graphNotMeasured = "not-measured" // the construct phase never established the shape
|
||||
)
|
||||
|
||||
// graphRows classify what a restart inherited from a fan-out. There are two
|
||||
// outcome rows rather than one because a fan-out that died mid-flight holds two
|
||||
// populations with different answers: the items that had already settled, whose
|
||||
// answers the store owns, and the one still executing, which nothing on disk
|
||||
// has ever been told about. One label over both reports the better half.
|
||||
func graphRows(before, after Observation) []row {
|
||||
return []row{
|
||||
ghostRow(before, after),
|
||||
dispatchTurnRow(before, after),
|
||||
settledOutcomeRow(before, after),
|
||||
interruptedOutcomeRow(before, after),
|
||||
graphStructureRow(before, after),
|
||||
graphSemanticsRow(before, after),
|
||||
childProvenanceRow(before, after),
|
||||
executionJournalRow(before, after),
|
||||
rebuildStateRow(before, after),
|
||||
rebuildTopologyRow(before, after),
|
||||
rebuildEnvelopeRow(before, after),
|
||||
rebuildInterruptedRow(before, after),
|
||||
rebuildIdentityRow(before, after),
|
||||
executionTopologyRow(before, after),
|
||||
executionKindRow(before, after),
|
||||
executionContextRow(before, after),
|
||||
graphObligationRow(before, after),
|
||||
}
|
||||
}
|
||||
|
||||
// runningWorkers are the nodes that were executing when the process died. They
|
||||
// are the arm's premise: an arm that establishes none has measured a fan-out
|
||||
// nobody interrupted, whatever else it recorded.
|
||||
func runningWorkers(o Observation) []string {
|
||||
var out []string
|
||||
for _, n := range o.Graph.Nodes {
|
||||
if n.Kind == agentgraph.KindWorker && n.State == agentgraph.StateRunning {
|
||||
out = append(out, n.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// executedWorkers are the items that ran to a terminal state. Adopted nodes are
|
||||
// excluded on purpose: nothing executed under them, so the store owes them no
|
||||
// record and counting them would read a correct store as incomplete.
|
||||
func executedWorkers(o Observation) []string {
|
||||
var out []string
|
||||
for _, n := range o.Graph.Nodes {
|
||||
if n.Kind != agentgraph.KindWorker || n.State == "" || n.State == agentgraph.StateAdopted {
|
||||
continue
|
||||
}
|
||||
if n.State.Terminal() {
|
||||
out = append(out, n.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ghostRow asks the one question with a single acceptable answer: does anything
|
||||
// the new process can read still present this work as running? A graph node and
|
||||
// a store record are both such a claim, and neither has an owner behind it —
|
||||
// the goroutine that would have settled them died with the first process.
|
||||
func ghostRow(before, after Observation) row {
|
||||
ghosts := append(runningWorkers(after), childrenInState(after, string(sessionstore.SubagentRunning))...)
|
||||
verdict := verdictHolds
|
||||
if len(ghosts) > 0 {
|
||||
verdict = verdictViolated
|
||||
}
|
||||
return row{
|
||||
Semantic: "work still presented as running", Authority: "the graph and the sub-agent store",
|
||||
Artifact: "subagents/<ref>.meta.json",
|
||||
Reconstruction: "nothing may read as running once its owner is gone",
|
||||
Before: orNone(join(runningWorkers(before))) + " running at death",
|
||||
After: orNone(join(ghosts)), Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchTurnRow is the row underneath every other loss here: whether the turn
|
||||
// that opened the fan-out reached the transcript at all. A turn is appended
|
||||
// when it ends, so a process that dies inside one takes the request with it —
|
||||
// and a graph reconstruction has nothing to attach to even in principle.
|
||||
func dispatchTurnRow(before, after Observation) row {
|
||||
verdict := verdictPersisted
|
||||
switch {
|
||||
case !before.FanOutTurn:
|
||||
verdict = verdictNotMeasured
|
||||
case !after.FanOutTurn:
|
||||
verdict = verdictLost
|
||||
}
|
||||
return row{
|
||||
Semantic: "the turn that dispatched the fan-out", Authority: "session transcript + event log",
|
||||
Artifact: "<stem>.jsonl, <stem>.events.jsonl", Reconstruction: "appended when the turn ends",
|
||||
Before: recordedText(before), After: recordedText(after), Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
func recordedText(o Observation) string {
|
||||
if o.FanOutTurn {
|
||||
return fmt.Sprintf("in the transcript (%d rows)", o.Transcript.Messages)
|
||||
}
|
||||
return fmt.Sprintf("absent (%d rows)", o.Transcript.Messages)
|
||||
}
|
||||
|
||||
// settledOutcomeRow classifies the items that had already finished. Their
|
||||
// answers are what a later turn reads back, so this is the row that decides
|
||||
// whether a fan-out's history is durable at all.
|
||||
func settledOutcomeRow(before, after Observation) row {
|
||||
outcome, verdict := settledOutcome(before, after)
|
||||
return row{
|
||||
Semantic: "items that had settled before the death", Authority: "the graph fold and the sub-agent store",
|
||||
Artifact: "subagents/<ref>.meta.json",
|
||||
Reconstruction: "one durable child record per item that executed",
|
||||
Before: fmt.Sprintf("%d executed: %s", len(executedWorkers(before)), graphSummary(before)),
|
||||
After: outcome, Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
func settledOutcome(before, after Observation) (string, string) {
|
||||
executed := len(executedWorkers(before))
|
||||
switch {
|
||||
case len(before.Graph.Nodes) == 0:
|
||||
return graphNotMeasured, verdictNotMeasured
|
||||
case graphIdentical(before, after):
|
||||
return graphExact, verdictExact
|
||||
case executed == 0:
|
||||
return graphNotMeasured, verdictNotMeasured
|
||||
case len(after.Children.Facts) == 0:
|
||||
return graphLostSilent, verdictViolated
|
||||
case len(after.Children.Facts) < executed:
|
||||
return fmt.Sprintf("%s (%d of %d children)", graphLossy, len(after.Children.Facts), executed), verdictLossy
|
||||
default:
|
||||
return graphLossy, verdictLossy
|
||||
}
|
||||
}
|
||||
|
||||
// interruptedOutcomeRow classifies the item that was still executing. This is
|
||||
// the row the arm exists for: the graph is gone either way, and what separates
|
||||
// a defensible design from a durable gap is whether anything the new process
|
||||
// can read still says that work was opened and cut.
|
||||
func interruptedOutcomeRow(before, after Observation) row {
|
||||
outcome, verdict := interruptedOutcome(before, after)
|
||||
return row{
|
||||
Semantic: "the item still executing at the death", Authority: "the sub-agent store and the transcript",
|
||||
Artifact: "subagents/<ref>.meta.json, <stem>.jsonl",
|
||||
Reconstruction: "an interrupted child record, or an unsettled call the transcript still owes",
|
||||
Before: orNone(join(runningWorkers(before))), After: outcome, Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
func interruptedOutcome(before, after Observation) (string, string) {
|
||||
switch {
|
||||
case len(before.Graph.Nodes) == 0 || len(runningWorkers(before)) == 0:
|
||||
return graphNotMeasured, verdictNotMeasured
|
||||
case len(runningWorkers(after)) > 0 || len(childrenInState(after, string(sessionstore.SubagentRunning))) > 0:
|
||||
return graphGhost, verdictViolated
|
||||
case len(after.InterruptedExecutions) > 0:
|
||||
return graphInterrupted, verdictLossy
|
||||
case len(childrenInState(after, string(sessionstore.SubagentInterrupted))) > 0:
|
||||
return graphInterrupted, verdictLossy
|
||||
case len(after.Obligation.UnansweredCalls) > 0 || after.Obligation.InterruptionMarked > 0:
|
||||
return graphInterrupted, verdictLossy
|
||||
default:
|
||||
return graphLostSilent, verdictViolated
|
||||
}
|
||||
}
|
||||
|
||||
// graphIdentical compares the whole graph, not its node count. Two graphs with
|
||||
// the same nodes and different grants are not the same graph, and a comparison
|
||||
// that only counted would have called that exact.
|
||||
func graphIdentical(before, after Observation) bool {
|
||||
return graphNodes(before) == graphNodes(after) &&
|
||||
graphEdges(before) == graphEdges(after) &&
|
||||
graphMap(before.Graph.Grants) == graphMap(after.Graph.Grants) &&
|
||||
graphMap(before.Graph.Waits) == graphMap(after.Graph.Waits)
|
||||
}
|
||||
|
||||
// graphStructureRow is the shape a reader asks for: which nodes, and the typed
|
||||
// edges between them. Spawn, depends, context and adopt are separate facts, and
|
||||
// a restart that kept the nodes while losing the edges kept a list, not a graph.
|
||||
func graphStructureRow(before, after Observation) row {
|
||||
return valueRow("run graph edges", "event sink (GraphDelta)",
|
||||
"none observed", "fold GraphDelta events, if any survive",
|
||||
graphEdges(before), graphEdges(after), false)
|
||||
}
|
||||
|
||||
// graphSemanticsRow is the authority envelope and the reason a node waited.
|
||||
// They are called out apart from the structure because nothing else in the
|
||||
// system records them: a child's persisted metadata carries its model and its
|
||||
// profile, and says nothing about what it was allowed to touch.
|
||||
func graphSemanticsRow(before, after Observation) row {
|
||||
return valueRow("run graph grants and waits", "event sink (GraphDelta)",
|
||||
"none observed", "no durable source carries an authority envelope",
|
||||
graphEnvelope(before), graphEnvelope(after), false)
|
||||
}
|
||||
|
||||
func graphEnvelope(o Observation) string {
|
||||
grants, waits := graphMap(o.Graph.Grants), graphMap(o.Graph.Waits)
|
||||
if grants == "" && waits == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("grants[%s] waits[%s]", orNone(grants), orNone(waits))
|
||||
}
|
||||
|
||||
// childProvenanceRow is what the store still owns, which is the evidence behind
|
||||
// an interrupted classification. It is reported as its own row so a reader can
|
||||
// see the reconstruction the label stands on rather than trusting the label.
|
||||
func childProvenanceRow(before, after Observation) row {
|
||||
return valueRow("delegated runs the store owns", "SubagentStore, swept at boot",
|
||||
"subagents/<ref>.meta.json", "ListSubagentsByParent; CleanupStaleRunning marks stale running children interrupted",
|
||||
childSummary(before), childSummary(after), true)
|
||||
}
|
||||
|
||||
// graphObligationRow is the last fallback a reader has: a fleet call the
|
||||
// transcript records with no result is the host saying a fan-out was opened and
|
||||
// never settled, even when nothing else remembers it.
|
||||
func graphObligationRow(before, after Observation) row {
|
||||
return row{
|
||||
Semantic: "fan-out calls the transcript never settled", Authority: "the canonical transcript",
|
||||
Artifact: "<stem>.jsonl", Reconstruction: "tool calls with no result",
|
||||
Before: orNone(join(before.Obligation.UnansweredCalls)),
|
||||
After: orNone(join(after.Obligation.UnansweredCalls)), Verdict: verdictStable,
|
||||
}
|
||||
}
|
||||
|
||||
// rebuiltGraph is what the production fold makes of this session's durable
|
||||
// facts, join included: a record names its own execution, and an older one is
|
||||
// aliased to its parent call only where the journal opened one by that id.
|
||||
func rebuiltGraph(o Observation) execgraph.Result {
|
||||
opened := make(map[string]bool, len(o.Executions))
|
||||
for _, e := range o.Executions {
|
||||
opened[e.ID] = true
|
||||
}
|
||||
children := make([]execgraph.ChildOutcome, 0, len(o.Children.Facts))
|
||||
for _, f := range o.Children.Facts {
|
||||
identity := delegation.ResolveExecutionIdentity(
|
||||
sessionstore.SubagentMeta{ExecutionID: f.ExecutionID, ParentToolCallID: f.ParentToolCallID},
|
||||
func(id string) bool { return opened[id] })
|
||||
children = append(children, execgraph.ChildOutcome{
|
||||
Execution: identity.Execution, Ref: f.Ref, Status: f.Status,
|
||||
})
|
||||
}
|
||||
return execgraph.Rebuild(o.Executions, children, nil)
|
||||
}
|
||||
|
||||
// rebuildStateRow compares the states the fold produces against the ones the
|
||||
// dying process drew. Nodes whose owner disappeared are excluded and reported
|
||||
// by their own row: putting those back as running is the one outcome this
|
||||
// whole line of work exists to prevent.
|
||||
func rebuildStateRow(before, after Observation) row {
|
||||
rebuilt := rebuiltGraph(after)
|
||||
cut := map[string]bool{}
|
||||
for _, i := range rebuilt.Interrupted {
|
||||
cut[i.Execution] = true
|
||||
}
|
||||
return valueRow("rebuilt node states", "execgraph.Rebuild over journal + store",
|
||||
"<stem>.execution.jsonl + subagents/<ref>.meta.json",
|
||||
"recomputed from durable facts, never restored",
|
||||
statesExcept(graphOf(before), cut), statesExcept(rebuilt.Graph, cut), false)
|
||||
}
|
||||
|
||||
// rebuildTopologyRow compares every typed edge. Spawn and depends are stated by
|
||||
// the openings, adopt names its source, and context is derived — so a mismatch
|
||||
// here says which of those four is not yet recoverable.
|
||||
func rebuildTopologyRow(before, after Observation) row {
|
||||
return valueRow("rebuilt topology", "execgraph.Rebuild over journal + store",
|
||||
"<stem>.execution.jsonl", "spawn and depends stated, adopt named, context derived",
|
||||
edgeSummary(graphOf(before)), edgeSummary(rebuiltGraph(after).Graph), false)
|
||||
}
|
||||
|
||||
// rebuildEnvelopeRow is the authority and the wait provenance together: what
|
||||
// each item was allowed to touch, and why admission was first refused.
|
||||
func rebuildEnvelopeRow(before, after Observation) row {
|
||||
return valueRow("rebuilt grants and wait causes", "execgraph.Rebuild over journal + store",
|
||||
"<stem>.execution.jsonl", "recorded at the opening and at the first refusal",
|
||||
envelopeSummary(graphOf(before)), envelopeSummary(rebuiltGraph(after).Graph), false)
|
||||
}
|
||||
|
||||
// rebuildInterruptedRow accounts for the nodes the state row leaves out. They
|
||||
// are not dropped: the fold names them as interruptions, split by whether they
|
||||
// had reached a slot. A node the dying process showed live and the rebuild
|
||||
// mentions nowhere would be a loss the state row could not see.
|
||||
func rebuildInterruptedRow(before, after Observation) row {
|
||||
rebuilt := rebuiltGraph(after)
|
||||
var got []string
|
||||
for _, i := range rebuilt.Interrupted {
|
||||
kind := execjournal.InterruptedBeforeStart
|
||||
if i.Started {
|
||||
kind = execjournal.InterruptedDuringExecution
|
||||
}
|
||||
got = append(got, i.Execution+":"+kind)
|
||||
}
|
||||
sort.Strings(got)
|
||||
return valueRow("nodes the rebuild will not call live", "execgraph.Rebuild over journal + store",
|
||||
"<stem>.execution.jsonl", "an execution with no owner is named, never redrawn as running",
|
||||
liveAtDeath(before), join(got), false)
|
||||
}
|
||||
|
||||
// liveAtDeath is what the dying process still showed as someone's work.
|
||||
func liveAtDeath(o Observation) string {
|
||||
var out []string
|
||||
for _, n := range o.Graph.Nodes {
|
||||
if n.Kind == agentgraph.KindGroup {
|
||||
continue
|
||||
}
|
||||
switch n.State {
|
||||
case agentgraph.StateRunning:
|
||||
out = append(out, n.ID+":"+execjournal.InterruptedDuringExecution)
|
||||
case agentgraph.StateQueued, agentgraph.StatePending:
|
||||
out = append(out, n.ID+":"+execjournal.InterruptedBeforeStart)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
// rebuildIdentityRow is what a reader sees on a node beyond its state: the
|
||||
// label it was dispatched under, and which worker ran it. Only one of those is
|
||||
// recorded, so this row is where a remaining gap shows up rather than hiding
|
||||
// inside an exact verdict about states.
|
||||
func rebuildIdentityRow(before, after Observation) row {
|
||||
return valueRow("rebuilt node identity", "execgraph.Rebuild over journal + store",
|
||||
"<stem>.execution.jsonl", "label from the opening; the worker override is recorded nowhere",
|
||||
identitySummary(graphOf(before)), identitySummary(rebuiltGraph(after).Graph), false)
|
||||
}
|
||||
|
||||
func identitySummary(g agentgraph.Graph) string {
|
||||
var out []string
|
||||
for _, n := range g.Nodes {
|
||||
if n.Kind != agentgraph.KindWorker {
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s[label=%s profile=%s model=%s effort=%s]",
|
||||
n.ID, orNone(n.Label), orNone(n.Profile), orNone(n.Model), orNone(n.Effort)))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
// statesExcept renders worker states, skipping the executions whose owner is
|
||||
// gone. Those are not a state the rebuild may assert, and comparing them would
|
||||
// demand exactly the ghost this refuses to draw.
|
||||
func statesExcept(g agentgraph.Graph, skip map[string]bool) string {
|
||||
var out []string
|
||||
for _, n := range g.Nodes {
|
||||
if n.Kind == agentgraph.KindGroup || skip[n.ID] {
|
||||
continue
|
||||
}
|
||||
out = append(out, n.ID+":"+string(n.State))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func edgeSummary(g agentgraph.Graph) string {
|
||||
out := make([]string, 0, len(g.Edges))
|
||||
for _, e := range g.Edges {
|
||||
out = append(out, fmt.Sprintf("%s-%s->%s", e.From, e.Kind, e.To))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func envelopeSummary(g agentgraph.Graph) string {
|
||||
var out []string
|
||||
for _, n := range g.Nodes {
|
||||
if n.Grant != "" {
|
||||
out = append(out, n.ID+"="+string(n.Grant))
|
||||
}
|
||||
if n.Wait != "" {
|
||||
out = append(out, n.ID+"@"+string(n.Wait))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
// executionJournalRow is the record written before the work started, which is
|
||||
// the only artifact here that can outlive an unfinished turn. It is what a
|
||||
// restart reads to say a delegation existed at all.
|
||||
func executionJournalRow(before, after Observation) row {
|
||||
return valueRow("delegations the journal recorded", "execution journal, written before dispatch",
|
||||
"<stem>.execution.jsonl", "folded from openings and settlings",
|
||||
executionSummary(before), executionSummary(after), true)
|
||||
}
|
||||
|
||||
// executionTopologyRow is the ordering a restart inherits. An item that never
|
||||
// started is explained by what it was held behind, so the topology has to
|
||||
// survive the process that knew it — and survive unchanged, or the explanation
|
||||
// is a different graph's.
|
||||
func executionTopologyRow(before, after Observation) row {
|
||||
return valueRow("declared ordering between items", "the fan-out plan, recorded at the opening",
|
||||
"<stem>.execution.jsonl", "read back with the openings, never re-derived",
|
||||
topologySummary(before), topologySummary(after), true)
|
||||
}
|
||||
|
||||
func topologySummary(o Observation) string {
|
||||
var out []string
|
||||
for _, e := range o.Executions {
|
||||
if len(e.DependsOn) == 0 {
|
||||
continue
|
||||
}
|
||||
up := append([]string(nil), e.DependsOn...)
|
||||
sort.Strings(up)
|
||||
out = append(out, e.ID+"<-"+strings.Join(up, "+"))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
// executionKindRow holds the two interruptions apart against what the dying
|
||||
// process actually showed. The item the graph had running must come back as one
|
||||
// that reached a slot; the one its dependency blocked must come back as one
|
||||
// that did not. A classification agreeing with neither is a label, not a fact.
|
||||
func executionKindRow(before, after Observation) row {
|
||||
running := runningWorkers(before)
|
||||
verdict := verdictHolds
|
||||
if len(after.InterruptedExecutions) == 0 {
|
||||
verdict = verdictNotMeasured
|
||||
}
|
||||
for _, e := range after.InterruptedExecutions {
|
||||
want := execjournal.InterruptedBeforeStart
|
||||
if slices.Contains(running, e.ID) {
|
||||
want = execjournal.InterruptedDuringExecution
|
||||
}
|
||||
if e.Interruption() != want {
|
||||
verdict = verdictViolated
|
||||
}
|
||||
}
|
||||
return row{
|
||||
Semantic: "how each interruption is classified", Authority: "the execution journal's start records",
|
||||
Artifact: "<stem>.execution.jsonl", Reconstruction: "started at death -> during-execution, never started -> before-start",
|
||||
Before: orNone(join(running)) + " running at death", After: interruptionKinds(after), Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
func interruptionKinds(o Observation) string {
|
||||
if len(o.InterruptedExecutions) == 0 {
|
||||
return "none"
|
||||
}
|
||||
out := make([]string, 0, len(o.InterruptedExecutions))
|
||||
for _, e := range o.InterruptedExecutions {
|
||||
out = append(out, e.ID+":"+e.Interruption())
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
// executionContextRow is the other side of that record: what the next request
|
||||
// is told. It must state provenance and never offer a continuation, so the
|
||||
// count is checked against the interruptions rather than against a fixed
|
||||
// number — a block with nothing behind it is a ghost in prose.
|
||||
func executionContextRow(before, after Observation) row {
|
||||
verdict := verdictHolds
|
||||
if blocksOwed(before) != before.ModelSeesInterruptedExecution ||
|
||||
blocksOwed(after) != after.ModelSeesInterruptedExecution {
|
||||
verdict = verdictViolated
|
||||
}
|
||||
return row{
|
||||
Semantic: "what the next request says about them", Authority: "control, projected per request",
|
||||
Artifact: "none: derived, never stored", Reconstruction: "one block while an interrupted execution stands, none otherwise",
|
||||
Before: executionBlocks(before), After: executionBlocks(after), Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
func blocksOwed(o Observation) int {
|
||||
if len(o.InterruptedExecutions) > 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func executionBlocks(o Observation) string {
|
||||
return fmt.Sprintf("%d block(s) for %d interrupted", o.ModelSeesInterruptedExecution, len(o.InterruptedExecutions))
|
||||
}
|
||||
|
||||
func executionSummary(o Observation) string {
|
||||
if len(o.Executions) == 0 {
|
||||
return ""
|
||||
}
|
||||
open, started := 0, 0
|
||||
for _, e := range o.Executions {
|
||||
if e.Open() {
|
||||
open++
|
||||
}
|
||||
if e.Started() {
|
||||
started++
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%d opened, %d started, %d still open, %d with no owner here",
|
||||
len(o.Executions), started, open, len(o.InterruptedExecutions))
|
||||
}
|
||||
|
||||
func childrenInState(o Observation, status string) []string {
|
||||
var out []string
|
||||
for _, f := range o.Children.Facts {
|
||||
if f.Status == status {
|
||||
out = append(out, f.Ref)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func childSummary(o Observation) string {
|
||||
if len(o.Children.Facts) == 0 {
|
||||
if o.Children.Err != "" {
|
||||
return "error: " + o.Children.Err
|
||||
}
|
||||
return ""
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, f := range o.Children.Facts {
|
||||
counts[f.Status]++
|
||||
}
|
||||
keys := make([]string, 0, len(counts))
|
||||
for k := range counts {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
|
||||
}
|
||||
return fmt.Sprintf("%d child(ren): %s", len(o.Children.Facts), strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
// graphSummary is the fan-out as the dying process last saw it, which is the
|
||||
// "before" every graph row is compared against.
|
||||
func graphSummary(o Observation) string {
|
||||
if len(o.Graph.Nodes) == 0 {
|
||||
return "no fan-out"
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, n := range o.Graph.Nodes {
|
||||
if n.Kind == agentgraph.KindWorker {
|
||||
counts[string(n.State)]++
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(counts))
|
||||
for k := range counts {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
|
||||
}
|
||||
return fmt.Sprintf("%d node(s), %d edge(s): %s",
|
||||
len(o.Graph.Nodes), len(o.Graph.Edges), strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
// graphArmInvalid refuses an arm that never reached the state it names. The
|
||||
// running arms need a child in flight at death; every fan-out arm needs a
|
||||
// fan-out at all. Reporting a smaller shape as the thing that was interrupted
|
||||
// is how a probe answers a question nobody asked.
|
||||
func graphArmInvalid(arm string, before Observation) string {
|
||||
if len(before.Graph.Nodes) == 0 {
|
||||
return "no fan-out was published before the process exited, so nothing was measured"
|
||||
}
|
||||
if arm == armGraphCompleted {
|
||||
if len(runningWorkers(before)) > 0 {
|
||||
return "the fan-out was still running at exit, so this is not the completed arm"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if len(runningWorkers(before)) == 0 {
|
||||
return "no child was executing when the process died, so nothing was interrupted"
|
||||
}
|
||||
if arm == armGraphMixed {
|
||||
return mixedArmInvalid(before)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// mixedArmInvalid holds the density arm to its own premise. Its whole claim is
|
||||
// that one death carried every reachable state at once, and an arm that reached
|
||||
// three of them measures a different scenario than the one it reports.
|
||||
func mixedArmInvalid(before Observation) string {
|
||||
var missing []string
|
||||
for _, state := range wantedStates(armGraphMixed) {
|
||||
if !holdsAll(agentgraph.Graph{Nodes: before.Graph.Nodes}, []agentgraph.NodeState{state}) {
|
||||
missing = append(missing, string(state))
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return "the fan-out never reached " + join(missing) + ", so the arm is not the mixed shape it reports"
|
||||
}
|
||||
if !carriesGrant(before, agentgraph.GrantRead) || !carriesGrant(before, agentgraph.GrantWrite) {
|
||||
return "the fan-out carried only one kind of grant, so the envelope row measures one side of it"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func carriesGrant(o Observation, want agentgraph.Grant) bool {
|
||||
for _, got := range o.Graph.Grants {
|
||||
if got == string(want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// graphEdges renders the typed relations in a stable order. Sorting is not
|
||||
// cosmetic: the fold keeps first-seen order, which two processes reach by
|
||||
// different routes, and an unsorted comparison would report a reordering as a
|
||||
// changed graph.
|
||||
func graphEdges(o Observation) string {
|
||||
if len(o.Graph.Edges) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := make([]string, 0, len(o.Graph.Edges))
|
||||
for _, e := range o.Graph.Edges {
|
||||
out = append(out, fmt.Sprintf("%s-%s->%s", e.From, e.Kind, e.To))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
@@ -1,602 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/agentgraph"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
// The interactive host-initiated arms, named for the one surface they measure: a
|
||||
// person typing /<skill> into a live session. The headless counterpart is not
|
||||
// measured here — it is caller-owned and its cancellation need not match, and
|
||||
// reading one for the other is how a real number gets the wrong subject.
|
||||
const (
|
||||
armHostCompleted = "interactive-host-skill-completed"
|
||||
armHostQueued = "interactive-host-skill-queued-crash"
|
||||
armHostRunning = "interactive-host-skill-running-crash"
|
||||
armHostCancel = "interactive-host-skill-cancel"
|
||||
// The headless counterpart: the controller's own synchronous entry point,
|
||||
// whose caller owns both the context and the cancellation. It is measured
|
||||
// apart from the four above and compared to them, never read for them.
|
||||
armHeadlessCompleted = "headless-host-skill-completed"
|
||||
armHeadlessRunningCancel = "headless-host-skill-running-cancel"
|
||||
armHeadlessQueuedCancel = "headless-host-skill-queued-cancel"
|
||||
)
|
||||
|
||||
func headlessHostArm(name string) bool {
|
||||
switch name {
|
||||
case armHeadlessCompleted, armHeadlessRunningCancel, armHeadlessQueuedCancel:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// callerStopArm names the arms a caller stops through its own context. The
|
||||
// controller never admitted these turns, so its own cancel holds nothing for
|
||||
// them — reading one surface with the other's stop is a mistake this probe has
|
||||
// already made once.
|
||||
func callerStopArm(name string) bool {
|
||||
return name == armHeadlessRunningCancel || name == armHeadlessQueuedCancel
|
||||
}
|
||||
|
||||
func hostExecArm(name string) bool {
|
||||
switch name {
|
||||
case armHostCompleted, armHostQueued, armHostRunning, armHostCancel:
|
||||
return true
|
||||
}
|
||||
return headlessHostArm(name)
|
||||
}
|
||||
|
||||
// queuedHostArm names the arms whose ceiling is filled before the invocation.
|
||||
func queuedHostArm(name string) bool {
|
||||
return name == armHostQueued || name == armHeadlessQueuedCancel
|
||||
}
|
||||
|
||||
// The four identities one host-started run can be known by. They are printed
|
||||
// apart because this is the first surface where they may not coincide: for a
|
||||
// model-delegated run the execution and the parent call are one string, and
|
||||
// that has only ever been true because a tool call produced every durable
|
||||
// execution there was.
|
||||
const (
|
||||
probeHostDispatch = "probe:host-dispatch-id"
|
||||
probeHostChildRef = "probe:host-child-ref"
|
||||
probeHostParentID = "probe:host-parent-lineage"
|
||||
probeHostExecution = "probe:host-execution-id"
|
||||
// probeHostStanding is what the call itself was doing at the reading:
|
||||
// admitted or held back, its child in the provider or not.
|
||||
probeHostStanding = "probe:host-standing"
|
||||
// probeHostAuthority is the snapshot at the same instant: what a reader
|
||||
// starting from the authority would hold, on either surface.
|
||||
probeHostAuthority = "probe:host-authority"
|
||||
)
|
||||
|
||||
// hostSlashInput is what a person types. The task carries the child sentinel so
|
||||
// the scripted provider knows how this arm's child behaves.
|
||||
func hostSlashInput(arm string) string {
|
||||
return "/" + probeSkillName + " " + hostChildSentinel(arm) + " host-started work"
|
||||
}
|
||||
|
||||
// runHostExecConstruct drives one interactive slash invocation and dies where
|
||||
// this arm is about. The queued arm fills the ceiling first with a background
|
||||
// fleet, which is dispatched through Run rather than Submit: Run is never
|
||||
// admitted through the turn gate, so the slash the arm measures can still be.
|
||||
func runHostExecConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
|
||||
if queuedHostArm(arm) {
|
||||
go func() { _ = ctrl.Run(ctx, fanOutTurn(turn+1, fleetHolderSentinel)) }()
|
||||
if err := waitForCeiling(prov); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Each surface is entered and stopped by the owner it has: the controller's
|
||||
// gate for a turn it admitted, and the caller's own context for a
|
||||
// synchronous call it never did.
|
||||
callerCtx, stopCaller := context.WithCancel(ctx)
|
||||
defer stopCaller()
|
||||
var answered atomic.Bool
|
||||
if headlessHostArm(arm) {
|
||||
go func() {
|
||||
_, _ = ctrl.RunSubagentProfile(callerCtx, probeSkillName, hostHeadlessTask(arm), false)
|
||||
answered.Store(true)
|
||||
}()
|
||||
} else {
|
||||
go ctrl.Submit(hostSlashInput(arm))
|
||||
}
|
||||
if err := waitForHostExec(ctrl, prov, arm, &answered); err != nil {
|
||||
return err
|
||||
}
|
||||
stop := ""
|
||||
switch {
|
||||
case arm == armHostCancel:
|
||||
stop = stopHostExec(ctrl, prov)
|
||||
case callerStopArm(arm):
|
||||
stop = stopHostCaller(ctrl, prov, arm, stopCaller)
|
||||
}
|
||||
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
|
||||
recordHostIdentities(&obs, ctrl, sink, prov, arm)
|
||||
if stop != "" {
|
||||
obs.Progress[probeStopReached] = []string{stop}
|
||||
}
|
||||
if err := writeObservation(root, obs); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordHostIdentities writes down all four names this run could be known by,
|
||||
// at one instant. A single "identity: none" would hide which of them exists and
|
||||
// which does not, and that difference is the whole question.
|
||||
func recordHostIdentities(obs *Observation, ctrl *control.Controller, sink *graphSink, prov *scripted, arm string) {
|
||||
obs.Progress[probeChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(hostChildSentinel(arm))))}
|
||||
obs.Progress[probeHostDispatch] = []string{orNone(join(hostDispatchIDs(sink)))}
|
||||
obs.Progress[probeHostChildRef] = []string{orNone(join(hostChildRefs(*obs)))}
|
||||
obs.Progress[probeHostParentID] = []string{orNone(join(hostParentLineage(*obs)))}
|
||||
obs.Progress[probeHostExecution] = []string{orNone(join(hostExecutions(*obs)))}
|
||||
obs.Progress[probeHostStanding] = []string{hostStanding(ctrl, prov, arm)}
|
||||
// The authority, read at the same instant. It is what a reader starts from
|
||||
// on either surface, so it is where the two are compared — the delta stream
|
||||
// exists only where a sink does, and a synchronous caller has none.
|
||||
obs.Progress[probeHostAuthority] = []string{hostAuthorityNodes(ctrl)}
|
||||
}
|
||||
|
||||
// hostHeadlessTask is what the controller's own entry point is asked to do.
|
||||
func hostHeadlessTask(arm string) string {
|
||||
return hostChildSentinel(arm) + " host-started work"
|
||||
}
|
||||
|
||||
func hostAuthorityNodes(ctrl *control.Controller) string {
|
||||
var out []string
|
||||
for _, n := range ctrl.ExecutionGraph().Graph.Nodes {
|
||||
if ofHostExecution(n.ID) {
|
||||
out = append(out, n.ID+":"+orDash(string(n.State)))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return orNone(join(out))
|
||||
}
|
||||
|
||||
// stopHostCaller stops work through the only owner a synchronous call has: the
|
||||
// context its caller passed in. The controller's gate never admitted this turn,
|
||||
// so asking it to stop one would be reading a surface with another's stop.
|
||||
func stopHostCaller(ctrl *control.Controller, prov *scripted, arm string, stop func()) string {
|
||||
stopped := time.Now()
|
||||
stop()
|
||||
child := hostChildSentinel(arm)
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for time.Now().Before(deadline) && !prov.fleets.held(childLeft(child)) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
live := "gone"
|
||||
if prov.fleets.held(childEntered(child)) && !prov.fleets.held(childLeft(child)) {
|
||||
live = "still running"
|
||||
}
|
||||
return fmt.Sprintf("turn=%s work=%s ctx=%t after=%s", turnStanding(ctrl, arm), live,
|
||||
prov.fleets.held(childCtxDone(child)), time.Since(stopped).Round(time.Second))
|
||||
}
|
||||
|
||||
func hostChildSentinel(arm string) string {
|
||||
if arm == armHostCompleted || arm == armHeadlessCompleted {
|
||||
return childDone
|
||||
}
|
||||
return childHang
|
||||
}
|
||||
|
||||
// hostDispatchIDs are the synthetic event ids the host minted so the child's
|
||||
// activity could nest under something on screen. They are UI identity, not a
|
||||
// call the model made, which is why no persisted lineage may carry one.
|
||||
func hostDispatchIDs(sink *graphSink) []string {
|
||||
var out []string
|
||||
for id := range sink.toolIDs() {
|
||||
if strings.HasPrefix(id, "slash-skill-") {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// hostChildRefs are the store's own artifact ids. They exist only once a child
|
||||
// transcript has been prepared, which is why they cannot serve as the identity
|
||||
// of work the scheduler is still holding back.
|
||||
func hostChildRefs(o Observation) []string {
|
||||
var out []string
|
||||
for _, f := range o.Children.Facts {
|
||||
if f.Kind == "skill" {
|
||||
out = append(out, f.Ref+"="+f.Status)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func hostParentLineage(o Observation) []string {
|
||||
var out []string
|
||||
for _, f := range o.Children.Facts {
|
||||
if f.Kind == "skill" {
|
||||
out = append(out, f.Ref+"→"+orDash(f.ParentToolCallID))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func hostExecutions(o Observation) []string {
|
||||
var out []string
|
||||
for _, e := range o.Executions {
|
||||
if ofHostExecution(e.ID) || strings.Contains(e.Name, probeSkillName) {
|
||||
out = append(out, e.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ofHostExecution matches both names this work can go by: the execution the host
|
||||
// owns, and the synthetic event id it mints for the screen. Reading only the
|
||||
// second is how an arm reports absence when what changed is the identity.
|
||||
func ofHostExecution(id string) bool {
|
||||
return strings.HasPrefix(id, "host-") || strings.HasPrefix(id, "slash-skill-")
|
||||
}
|
||||
|
||||
func hostStanding(ctrl *control.Controller, prov *scripted, arm string) string {
|
||||
return fmt.Sprintf("turn=%v ceiling=%t child=%t", ctrl.Running(),
|
||||
prov.fleets.held(childEntered(childHold)),
|
||||
prov.fleets.held(childEntered(hostChildSentinel(arm))))
|
||||
}
|
||||
|
||||
func waitForCeiling(prov *scripted) error {
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if prov.fleets.held(childEntered(childHold)) {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return errUnexpected("a fan-out holding the ceiling", "none reached the provider")
|
||||
}
|
||||
|
||||
// waitForHostExec blocks until the invocation stands where this arm dies. The
|
||||
// queued arm asks a negative — the child never arrived while the ceiling was
|
||||
// full — so it settles and asks again rather than reading a race.
|
||||
func waitForHostExec(ctrl *control.Controller, prov *scripted, arm string, answered *atomic.Bool) error {
|
||||
child := hostChildSentinel(arm)
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
switch {
|
||||
case queuedHostArm(arm):
|
||||
// In flight, read through whichever owner this surface has: a turn
|
||||
// the controller admitted answers its gate, and a synchronous call
|
||||
// it never admitted answers only by returning.
|
||||
if hostInFlight(ctrl, arm, answered) && !prov.fleets.held(childEntered(child)) {
|
||||
time.Sleep(2 * time.Second)
|
||||
if prov.fleets.held(childEntered(child)) {
|
||||
return errUnexpected("a host invocation the scheduler was still holding back", "its child ran")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
case arm == armHostCompleted:
|
||||
if prov.fleets.held(childLeft(child)) && !ctrl.Running() {
|
||||
return nil
|
||||
}
|
||||
case arm == armHeadlessCompleted:
|
||||
if prov.fleets.held(childLeft(child)) {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
if prov.fleets.held(childEntered(child)) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return errUnexpected("a host invocation "+hostWanted(arm), hostStanding(ctrl, prov, arm))
|
||||
}
|
||||
|
||||
// hostInFlight reports that the invocation has not answered yet, asked of the
|
||||
// owner the surface actually has.
|
||||
func hostInFlight(ctrl *control.Controller, arm string, answered *atomic.Bool) bool {
|
||||
if headlessHostArm(arm) {
|
||||
return !answered.Load()
|
||||
}
|
||||
return ctrl.Running()
|
||||
}
|
||||
|
||||
func hostWanted(arm string) string {
|
||||
switch {
|
||||
case queuedHostArm(arm):
|
||||
return "held back by a full ceiling"
|
||||
case arm == armHostCompleted || arm == armHeadlessCompleted:
|
||||
return "that finished"
|
||||
}
|
||||
return "with its child inside the provider"
|
||||
}
|
||||
|
||||
// stopHostExec stops the work the way a person does: the turn's own cancel. A
|
||||
// slash invocation is admitted through the same gate a typed message is, so
|
||||
// that gate is the owner of its stop.
|
||||
func stopHostExec(ctrl *control.Controller, prov *scripted) string {
|
||||
stopped := time.Now()
|
||||
ctrl.Cancel()
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for time.Now().Before(deadline) && ctrl.Running() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
live := "gone"
|
||||
if prov.fleets.held(childEntered(childHang)) && !prov.fleets.held(childLeft(childHang)) {
|
||||
live = "still running"
|
||||
}
|
||||
return fmt.Sprintf("turn=%s work=%s ctx=%t after=%s", turnStanding(ctrl, armHostCancel), live,
|
||||
prov.fleets.held(childCtxDone(childHang)), time.Since(stopped).Round(time.Second))
|
||||
}
|
||||
|
||||
// The verdicts this arm needs. They are separate words because the failure
|
||||
// modes are separate facts, and one label over all of them would say less than
|
||||
// any of them does.
|
||||
const (
|
||||
// verdictIdentityGap is work the host admitted that no identity outliving
|
||||
// the process names. It is not about a lost record: none of the candidate
|
||||
// identities can carry one.
|
||||
verdictIdentityGap = "IDENTITY-GAP"
|
||||
// verdictResultDurable is an invocation whose child record survives while
|
||||
// the execution that produced it does not. A reader can see that a child of
|
||||
// this name was cut; nothing says which invocation it belonged to.
|
||||
verdictResultDurable = "record-only"
|
||||
)
|
||||
|
||||
func hostExecRows(arm string, before, after Observation) []row {
|
||||
return []row{
|
||||
hostAdmissionRow(arm, before),
|
||||
hostIdentityRow(before),
|
||||
hostGraphRow(before),
|
||||
hostJournalRow(before, after),
|
||||
hostStoreRow(before, after),
|
||||
hostLineageOracleRow(before, after),
|
||||
hostPreStartIdentityRow(arm, before, after),
|
||||
hostCancelRow(arm, before),
|
||||
hostRestartRow(arm, before, after),
|
||||
}
|
||||
}
|
||||
|
||||
func hostAdmissionRow(arm string, before Observation) row {
|
||||
entered := firstProgress(before, probeChildEntered) == "true"
|
||||
answer, verdict := "admitted: the child reached the provider", verdictHolds
|
||||
switch {
|
||||
case queuedHostArm(arm) && !entered:
|
||||
answer = "refused: the ceiling was full and the child never arrived"
|
||||
case queuedHostArm(arm):
|
||||
answer, verdict = "not held back: the child ran with the ceiling full", verdictViolated
|
||||
case !entered:
|
||||
answer, verdict = "the child never arrived", verdictNotMeasured
|
||||
}
|
||||
return row{
|
||||
Semantic: "does it ask the session scheduler", Authority: skillAuthority,
|
||||
Artifact: "none observed", Reconstruction: "in-process only",
|
||||
Before: answer + " [" + firstProgress(before, probeHostStanding) + "]",
|
||||
After: "—", Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
// hostIdentityRow prints all four names at once. This is the row the arm exists
|
||||
// for: for a model-delegated run the execution and the parent call are the same
|
||||
// string, and that has only ever held because a tool call produced every durable
|
||||
// execution there was.
|
||||
func hostIdentityRow(before Observation) row {
|
||||
return row{
|
||||
Semantic: "the four names this run could be known by", Authority: crossAuthority,
|
||||
Artifact: "the event stream, subagents/<ref>.meta.json, <stem>.execution.jsonl",
|
||||
Reconstruction: "read apart, because they may not coincide here",
|
||||
Before: fmt.Sprintf("dispatch=%s child=%s lineage=%s execution=%s",
|
||||
firstProgress(before, probeHostDispatch), firstProgress(before, probeHostChildRef),
|
||||
firstProgress(before, probeHostParentID), firstProgress(before, probeHostExecution)),
|
||||
After: "—", Verdict: verdictHolds,
|
||||
}
|
||||
}
|
||||
|
||||
func hostGraphRow(before Observation) row {
|
||||
var drawn []string
|
||||
for _, n := range before.Graph.Nodes {
|
||||
if ofHostExecution(n.ID) {
|
||||
drawn = append(drawn, n.ID+":"+string(n.State))
|
||||
}
|
||||
}
|
||||
sort.Strings(drawn)
|
||||
// The stream and the authority are reported together because only one of
|
||||
// them is surface-independent: a synchronous caller has no sink to draw on,
|
||||
// and a reader on either surface starts from the snapshot regardless.
|
||||
authority := firstProgress(before, probeHostAuthority)
|
||||
return row{
|
||||
Semantic: "the graph, drawn and reconstructable",
|
||||
Authority: liveAuthority + " and " + authorityName,
|
||||
Artifact: "<stem>.execution.jsonl", Reconstruction: "the stream where a sink exists; the authority always",
|
||||
Before: fmt.Sprintf("stream=%s authority=%s", orNone(join(drawn)), authority),
|
||||
After: "—", Verdict: presenceVerdict(authority != "none"),
|
||||
}
|
||||
}
|
||||
|
||||
func hostJournalRow(before, after Observation) row {
|
||||
return presenceRow("does the journal record it", journalAuthority, "<stem>.execution.jsonl",
|
||||
"ExecutionHistory over the whole session", hostExecutions(before), hostExecutions(after))
|
||||
}
|
||||
|
||||
func hostStoreRow(before, after Observation) row {
|
||||
return presenceRow("what the store holds for it", storeAuthority, "subagents/<ref>.meta.json",
|
||||
"ListSubagentsByParent over the transcript stem", hostChildRefs(before), hostChildRefs(after))
|
||||
}
|
||||
|
||||
// hostLineageOracleRow is the contract Step 8 respected and this must not
|
||||
// weaken. A synthetic host event id is UI identity; persisting one as a parent
|
||||
// would say the model issued a call it never made.
|
||||
func hostLineageOracleRow(before, after Observation) row {
|
||||
leaked := hostSyntheticLeaks(before)
|
||||
leaked = append(leaked, hostSyntheticLeaks(after)...)
|
||||
empty := hostLineageEmpty(before) && hostLineageEmpty(after)
|
||||
answer, verdict := "no persisted lineage names a synthetic host id, and every parent is empty", verdictHolds
|
||||
switch {
|
||||
case len(leaked) > 0:
|
||||
answer, verdict = "a synthetic host id was persisted as lineage: "+join(leaked), verdictViolated
|
||||
case !empty:
|
||||
answer, verdict = "a host-started run was filed under a parent call: "+
|
||||
join(hostParentLineage(after)), verdictViolated
|
||||
case len(hostChildRefs(before))+len(hostChildRefs(after)) == 0:
|
||||
verdict = verdictNotMeasured
|
||||
answer = "no child record exists to carry a lineage"
|
||||
}
|
||||
return row{
|
||||
Semantic: "synthetic host id persisted as lineage", Authority: storeAuthority,
|
||||
Artifact: "subagents/<ref>.meta.json", Reconstruction: "every persisted parent, read for host event ids",
|
||||
Before: answer, After: "—", Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
func hostSyntheticLeaks(o Observation) []string {
|
||||
var out []string
|
||||
for _, f := range o.Children.Facts {
|
||||
if strings.HasPrefix(f.ParentToolCallID, "slash-skill-") {
|
||||
out = append(out, f.Ref+"→"+f.ParentToolCallID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hostLineageEmpty(o Observation) bool {
|
||||
for _, f := range o.Children.Facts {
|
||||
if f.Kind == "skill" && strings.TrimSpace(f.ParentToolCallID) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// hostPreStartIdentityRow is what decides where an identity would have to be
|
||||
// created. Work the scheduler is holding back has no child artifact yet, so a
|
||||
// store ref cannot name it; if nothing else survives the process either, no
|
||||
// candidate identity exists at the moment the work first became real.
|
||||
func hostPreStartIdentityRow(arm string, before, after Observation) row {
|
||||
survives := len(hostExecutions(after)) > 0
|
||||
answer := "nothing that outlives the process names this work"
|
||||
verdict := verdictIdentityGap
|
||||
if survives {
|
||||
answer, verdict = "an execution the journal opened: "+join(hostExecutions(after)), verdictHolds
|
||||
}
|
||||
if !queuedHostArm(arm) && firstProgress(before, probeChildEntered) != "true" {
|
||||
verdict = verdictNotMeasured
|
||||
}
|
||||
return row{
|
||||
Semantic: "an identity that exists before the child does", Authority: crossAuthority,
|
||||
Artifact: "<stem>.execution.jsonl",
|
||||
Reconstruction: "the candidates, read against what the work needs",
|
||||
Before: hostAtDeath(arm), After: answer, Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
func hostAtDeath(arm string) string {
|
||||
switch {
|
||||
case queuedHostArm(arm):
|
||||
return "admitted-domain work, held back, no child artifact yet"
|
||||
case arm == armHostCompleted || arm == armHeadlessCompleted:
|
||||
return "ran and returned"
|
||||
}
|
||||
return "executing inside the provider"
|
||||
}
|
||||
|
||||
func hostCancelRow(arm string, before Observation) row {
|
||||
if arm != armHostCancel && !callerStopArm(arm) {
|
||||
return row{
|
||||
Semantic: "what a stop reaches", Authority: "the turn's own cancel",
|
||||
Artifact: "none observed", Reconstruction: "in-process only",
|
||||
Before: "not stopped in this arm", After: "—", Verdict: verdictNotMeasured,
|
||||
}
|
||||
}
|
||||
return row{
|
||||
Semantic: "what a stop reaches", Authority: "the turn's own cancel",
|
||||
Artifact: "subagents/<ref>.meta.json",
|
||||
Reconstruction: "the live stop and the record it left, kept apart",
|
||||
Before: firstProgress(before, probeStopReached) + " store=" + orNone(join(hostChildRefs(before))),
|
||||
After: "—", Verdict: verdictHolds,
|
||||
}
|
||||
}
|
||||
|
||||
// hostRestartRow classifies what the next process inherits. An answer that
|
||||
// survived while its execution did not is not a loss to the user, so it is not
|
||||
// called one; work that was executing and left nothing is.
|
||||
func hostRestartRow(arm string, before, after Observation) row {
|
||||
exec, children := hostExecutions(after), hostChildRefs(after)
|
||||
rebuilt := hostRebuilt(after)
|
||||
answer := fmt.Sprintf("execution=%s store=%s rebuilt=%s",
|
||||
orNone(join(exec)), orNone(join(children)), orNone(join(rebuilt)))
|
||||
return row{
|
||||
Semantic: "what a restart inherits", Authority: rebuildAuthority,
|
||||
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
|
||||
Reconstruction: "execgraph.Rebuild, then the store beside it",
|
||||
Before: hostAtDeath(arm), After: answer,
|
||||
Verdict: hostRestartVerdict(arm, before, exec, children),
|
||||
}
|
||||
}
|
||||
|
||||
// hostRestartVerdict is driven by what a next process can actually hold, not by
|
||||
// what the arm expected. A child record with nothing to place it is not silence:
|
||||
// a reader can still see that a child of this name was cut. What it cannot do is
|
||||
// say which invocation that was, and the identity row answers that separately —
|
||||
// folding the two would report a surviving record as a total loss.
|
||||
func hostRestartVerdict(arm string, before Observation, exec, children []string) string {
|
||||
switch {
|
||||
case len(exec) > 0:
|
||||
return verdictHolds
|
||||
case len(children) > 0:
|
||||
return verdictResultDurable
|
||||
case firstProgress(before, probeChildEntered) == "true" || queuedHostArm(arm):
|
||||
return graphLostSilent
|
||||
default:
|
||||
return verdictNotMeasured
|
||||
}
|
||||
}
|
||||
|
||||
// hostRebuilt is what a restart draws for this invocation. The whole rebuilt
|
||||
// graph is wider than the arm — the queued arm fills its ceiling with a fan-out,
|
||||
// and a fan-out records everything — so reading its nodes as the invocation's is
|
||||
// how an arm reports a gap as closed by evidence belonging to something else.
|
||||
func hostRebuilt(o Observation) []string {
|
||||
var out []string
|
||||
for _, n := range rebuiltGraph(o).Graph.Nodes {
|
||||
if n.Kind == agentgraph.KindGroup {
|
||||
continue
|
||||
}
|
||||
if ofHostExecution(n.ID) || strings.Contains(n.Label, probeSkillName) {
|
||||
out = append(out, n.ID+":"+string(n.State))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// hostExecArmInvalid reports the premise this arm could not establish.
|
||||
func hostExecArmInvalid(arm string, before Observation) string {
|
||||
entered := firstProgress(before, probeChildEntered) == "true"
|
||||
switch {
|
||||
case queuedHostArm(arm) && entered:
|
||||
return "the invocation ran while the ceiling was meant to be full"
|
||||
case !queuedHostArm(arm) && !entered:
|
||||
return "the invocation's child never reached the provider"
|
||||
case headlessHostArm(arm) && len(hostDispatchIDs2(before)) > 0:
|
||||
return "a synchronous call minted a dispatch id, so this was not the headless surface"
|
||||
case !headlessHostArm(arm) && len(hostDispatchIDs2(before)) == 0:
|
||||
return "the host minted no dispatch id, so this was not the slash surface"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// hostDispatchIDs2 reads the dispatch id back off the observation, where the
|
||||
// construct phase recorded what only a live sink could see.
|
||||
func hostDispatchIDs2(o Observation) []string {
|
||||
if got := firstProgress(o, probeHostDispatch); got != "none" && got != "unrecorded" {
|
||||
return strings.Split(got, ", ")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
// Two trees, read at once. A stop aimed at a turn must close the turn's own
|
||||
// tree and leave the job's alone; an explicit job stop does the reverse, and
|
||||
// reading only one of them is how a probe calls a violated handoff correct. The
|
||||
// manager's own root needs no separate question: a job still running with its
|
||||
// child's context open is the root answering directly.
|
||||
const (
|
||||
// armLineageForeground is the positive control: it proves the reading can
|
||||
// see an ordinary cancellation arrive.
|
||||
armLineageForeground = "lineage-foreground-cancel"
|
||||
// armLineageBackground is the one under suspicion.
|
||||
armLineageBackground = "lineage-background-cancel"
|
||||
// armLineageJobKill is the other positive control: without it, a reading
|
||||
// that could never observe a job's own cancellation would make the arm
|
||||
// above pass for the wrong reason.
|
||||
armLineageJobKill = "lineage-job-kill"
|
||||
)
|
||||
|
||||
func lineageArm(name string) bool {
|
||||
switch name {
|
||||
case armLineageForeground, armLineageBackground, armLineageJobKill:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runLineageConstruct drives one delegation, stops it the way this arm's owner
|
||||
// stops it, and reads both trees afterwards. It records six facts, kept apart
|
||||
// on purpose: a context that closed and a call that returned are different
|
||||
// events, and only their combination says whether a cancellation propagated or
|
||||
// something else ended the work.
|
||||
func runLineageConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
|
||||
ctrl.Send(fanOutTurn(turn+1, taskSentinel))
|
||||
if err := waitFor(60*time.Second, func() bool { return prov.fleets.held(childEntered(childHang)) }); err != nil {
|
||||
return errUnexpected("a delegated child inside the provider", "none arrived")
|
||||
}
|
||||
before := lineageReading(ctrl, prov, "before")
|
||||
|
||||
switch arm {
|
||||
case armLineageJobKill:
|
||||
jobs := ctrl.Jobs()
|
||||
if len(jobs) == 0 {
|
||||
return errUnexpected("a background job to stop", "none running")
|
||||
}
|
||||
for _, job := range jobs {
|
||||
ctrl.CancelJob(job.ID)
|
||||
}
|
||||
default:
|
||||
ctrl.Cancel()
|
||||
}
|
||||
// Long enough for a cancellation that was going to arrive to have arrived.
|
||||
time.Sleep(3 * time.Second)
|
||||
after := lineageReading(ctrl, prov, "after")
|
||||
|
||||
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
|
||||
obs.Progress[probeLineage] = []string{before, after}
|
||||
if err := writeObservation(root, obs); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
const probeLineage = "probe:lineage"
|
||||
|
||||
// lineageReading is the state of both trees at one instant, in the terms the
|
||||
// rows judge: the turn's gate, the job's own liveness, the child's context, the
|
||||
// child's call, and the durable record the orchestration keeps.
|
||||
func lineageReading(ctrl *control.Controller, prov *scripted, at string) string {
|
||||
return fmt.Sprintf("%s turn=%s job=%s childCtx=%s childCall=%s record=%s",
|
||||
at, turnGateStanding(ctrl), jobStanding(ctrl),
|
||||
yesNo(prov.fleets.held(childCtxDone(childHang)), "cancelled", "open"),
|
||||
yesNo(prov.fleets.held(childLeft(childHang)), "returned", "inside"),
|
||||
loneRecordStanding(ctrl))
|
||||
}
|
||||
|
||||
func turnGateStanding(ctrl *control.Controller) string {
|
||||
if ctrl.Running() {
|
||||
return "active"
|
||||
}
|
||||
return "ended"
|
||||
}
|
||||
|
||||
func jobStanding(ctrl *control.Controller) string {
|
||||
if len(ctrl.Jobs()) > 0 {
|
||||
return "running"
|
||||
}
|
||||
return "gone"
|
||||
}
|
||||
|
||||
func loneRecordStanding(ctrl *control.Controller) string {
|
||||
for _, e := range control.ExecutionHistory(ctrl.SessionPath()) {
|
||||
if e.ID == parallelLoneNodeID {
|
||||
return lifecycleOf(e)[1:]
|
||||
}
|
||||
}
|
||||
return "absent"
|
||||
}
|
||||
|
||||
func yesNo(ok bool, yes, no string) string {
|
||||
if ok {
|
||||
return yes
|
||||
}
|
||||
return no
|
||||
}
|
||||
|
||||
func waitFor(within time.Duration, ready func() bool) error {
|
||||
deadline := time.Now().Add(within)
|
||||
for time.Now().Before(deadline) {
|
||||
if ready() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("timed out")
|
||||
}
|
||||
|
||||
// lineageRows read the two trees against the ownership each arm is about. The
|
||||
// three sets of expectations are deliberately different: a fix that closes
|
||||
// everything on a stop satisfies the foreground arm and violates both others.
|
||||
func lineageRows(arm string, before Observation) []row {
|
||||
at := reading(before.Progress[probeLineage], 1)
|
||||
turnEnded := strings.Contains(at, "turn=ended")
|
||||
jobLive := strings.Contains(at, "job=running")
|
||||
childCancelled := strings.Contains(at, "childCtx=cancelled")
|
||||
recordOpen := !strings.Contains(at, "settled") && !strings.Contains(at, "record=absent")
|
||||
|
||||
rows := []row{lineageRow("the turn the stop was aimed at", at, "ends", turnEnded)}
|
||||
if arm == armLineageForeground {
|
||||
return append(rows,
|
||||
lineageRow("the child's own context", at, "closes with the turn that owned it", childCancelled),
|
||||
lineageRow("the delegation record", at, "is let go of", !recordOpen))
|
||||
}
|
||||
if arm == armLineageJobKill {
|
||||
return append(rows[:0],
|
||||
lineageRow("the job the stop was aimed at", at, "ends", !jobLive),
|
||||
lineageRow("the child's own context", at, "closes with the job that owned it", childCancelled),
|
||||
lineageRow("the delegation record", at, "is let go of", !recordOpen))
|
||||
}
|
||||
return append(rows,
|
||||
lineageRow("the job the turn no longer owns", at, "keeps running", jobLive),
|
||||
lineageRow("the child's own context", at, "is not closed by another owner's stop", !childCancelled),
|
||||
lineageRow("the delegation record", at, "stays open while its child runs", recordOpen))
|
||||
}
|
||||
|
||||
func lineageRow(semantic, got, want string, ok bool) row {
|
||||
return row{
|
||||
Semantic: semantic, Authority: "both trees, read at the same instant",
|
||||
Artifact: "none: in-process state", Reconstruction: want,
|
||||
Before: want, After: orNone(got), Verdict: held(ok),
|
||||
}
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/agentgraph"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
// The four single-delegation arms. A lone task is not another executor: it
|
||||
// reaches the same RunProfileSpec a fleet item does and asks the same scheduler
|
||||
// for a slot. What these ask is whether that shared chain is wired to the
|
||||
// durable authority the fan-out arms established — and where, exactly, it stops
|
||||
// being wired, which is a different question from "does it have a graph".
|
||||
const (
|
||||
// armTaskCompleted lets the delegation finish and the turn close. Nothing is
|
||||
// interrupted, so a loss here is not about interruption: it says the run's
|
||||
// provenance was never durable at all.
|
||||
armTaskCompleted = "task-completed"
|
||||
// armTaskRunning kills the process with the child mid-execution, the same
|
||||
// death the fan-out arms take.
|
||||
armTaskRunning = "task-running"
|
||||
// armTaskFgQueued is the live-truth arm: the ceiling is full when the
|
||||
// delegation asks, so the scheduler is holding it back. What the parent and
|
||||
// the graph say while that is true is the whole question — a picture that
|
||||
// says running while nothing has started is wrong before any restart.
|
||||
armTaskFgQueued = "task-foreground-queued"
|
||||
// armTaskBgQueued is the one the foreground arms cannot stand in for: a
|
||||
// background run hands its execution to a job and asks for a slot inside it,
|
||||
// so the parent tool call has already returned when the refusal happens.
|
||||
armTaskBgQueued = "task-background-queued"
|
||||
// armTaskBgRunning is the same handoff with the slot granted, which is where
|
||||
// ownership of the terminal moves from the caller to the job.
|
||||
armTaskBgRunning = "task-background-running"
|
||||
// The cancellation arms. They ask the one question the five before them
|
||||
// cannot: who owns the ending when work is stopped, on each side of the
|
||||
// boundary that decides whether it ever ran. A child the scheduler never
|
||||
// admitted produced nothing, so the store owes it no terminal at all.
|
||||
armTaskFgQueuedCancel = "task-foreground-queued-cancel"
|
||||
armTaskBgQueuedCancel = "task-background-queued-cancel"
|
||||
armTaskFgRunningCancel = "task-foreground-running-cancel"
|
||||
armTaskBgRunningCancel = "task-background-running-cancel"
|
||||
)
|
||||
|
||||
func cancelTaskArm(name string) bool {
|
||||
switch name {
|
||||
case armTaskFgQueuedCancel, armTaskBgQueuedCancel, armTaskFgRunningCancel, armTaskBgRunningCancel:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// beforeStartArm names the arms cancelled while the scheduler still held the
|
||||
// delegation, which is the half of the boundary the store must stay out of.
|
||||
func beforeStartArm(name string) bool {
|
||||
return name == armTaskFgQueuedCancel || name == armTaskBgQueuedCancel
|
||||
}
|
||||
|
||||
func loneTaskArm(name string) bool {
|
||||
switch name {
|
||||
case armTaskCompleted, armTaskRunning, armTaskFgQueued, armTaskBgQueued, armTaskBgRunning:
|
||||
return true
|
||||
}
|
||||
return cancelTaskArm(name)
|
||||
}
|
||||
|
||||
// queuedTaskArm names the arms that fill the ceiling before delegating, so the
|
||||
// scheduler has to refuse the delegation admission.
|
||||
func queuedTaskArm(name string) bool {
|
||||
return name == armTaskFgQueued || name == armTaskBgQueued || beforeStartArm(name)
|
||||
}
|
||||
|
||||
// childEntered names the record a child leaves when it reaches the provider.
|
||||
// The queued arms ask a negative — that one never did — which nothing else in
|
||||
// the probe can answer.
|
||||
func childEntered(sentinel string) string { return "child-entered:" + sentinel }
|
||||
|
||||
// childLeft names the record a child leaves when it returns. Entered without
|
||||
// left is a child still inside the provider — the state a cancellation that
|
||||
// never arrived leaves behind.
|
||||
func childLeft(sentinel string) string { return "child-left:" + sentinel }
|
||||
|
||||
// childCtxDone names the record a child leaves when its own context closes,
|
||||
// which is a different fact from having returned: a run whose caller stopped
|
||||
// waiting returns to nobody with its context still open.
|
||||
func childCtxDone(sentinel string) string { return "child-ctxdone:" + sentinel }
|
||||
|
||||
// probeChildEntered is where that answer rides into the observation. It is not
|
||||
// a status the kernel emits: the key names the probe so no row reads it as one.
|
||||
const probeChildEntered = "probe:delegated-child-entered"
|
||||
|
||||
func backgroundTaskArm(name string) bool {
|
||||
switch name {
|
||||
case armTaskBgQueued, armTaskBgRunning, armTaskBgQueuedCancel, armTaskBgRunningCancel,
|
||||
armLineageBackground, armLineageJobKill, armCancelBackground, armFleetActiveStore:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The sentinels one turn carries. The queued arm names the holder as well: its
|
||||
// ceiling has to be occupied before the delegation asks for a slot, or the
|
||||
// refusal it is built to record never happens.
|
||||
const (
|
||||
taskSentinel = "PROBE-TASK"
|
||||
taskCallID = "probe_task"
|
||||
taskHoldsFirst = fleetHolderSentinel + " " + taskSentinel
|
||||
)
|
||||
|
||||
// loneTaskCall is the delegation itself, dispatched through the tool a model
|
||||
// calls. Nothing here reaches past the tool's own arguments: the probe measures
|
||||
// the production path, so it must enter it the way a model does.
|
||||
func loneTaskCall(arm string) []provider.Chunk {
|
||||
prompt, description := childDone+" lone task", "completes"
|
||||
switch {
|
||||
case arm == armFleetActiveStore:
|
||||
// This delegation is the other occupant of the ceiling its arm shares
|
||||
// with a fan-out, and its own child is the only thing that can say the
|
||||
// slot is taken. Holding is how it hangs and says so at once.
|
||||
prompt, description = childHold+" lone task", "holds a slot to the death"
|
||||
case arm != armTaskCompleted:
|
||||
prompt, description = childHang+" lone task", "still executing at death"
|
||||
}
|
||||
args, _ := json.Marshal(map[string]any{
|
||||
"prompt": prompt, "description": description,
|
||||
"run_in_background": backgroundTaskArm(arm),
|
||||
})
|
||||
return []provider.Chunk{{
|
||||
Type: provider.ChunkToolCall,
|
||||
ToolCall: &provider.ToolCall{ID: taskCallID, Name: "task", Arguments: string(args)},
|
||||
}}
|
||||
}
|
||||
|
||||
// probeStepIDs are the host's open steps, in the order they have to be signed.
|
||||
// The arm whose turn ends has to close them: a delivery is refused while the
|
||||
// list is open, and a todo_write that marks an item completed on its own is
|
||||
// refused too — the sign-off is the only door.
|
||||
func probeStepIDs() []string { return []string{"probe_step_01", "probe_step_02", "probe_step_03"} }
|
||||
|
||||
// signStep signs off one step per round, which is what the host accepts: the
|
||||
// sign-off promotes the next item, so two in one round sign against a list that
|
||||
// no longer stands.
|
||||
func signStep(id string) []provider.Chunk {
|
||||
args, _ := json.Marshal(map[string]any{
|
||||
"step_id": id,
|
||||
"result": "the probe drove this step to its end",
|
||||
"evidence": []map[string]any{
|
||||
{"kind": "manual", "summary": "the probe established this state directly"},
|
||||
},
|
||||
})
|
||||
return []provider.Chunk{{
|
||||
Type: provider.ChunkToolCall,
|
||||
ToolCall: &provider.ToolCall{ID: "probe_sign_" + id, Name: "complete_step", Arguments: string(args)},
|
||||
}}
|
||||
}
|
||||
|
||||
// closesItsTurn names the arms whose turn has to reach its end. A turn that
|
||||
// called a tool cannot deliver while the host's step list is open, so these
|
||||
// sign it off one step per round — the sign-off promotes the next item, and two
|
||||
// in one round would sign against a list that no longer stands.
|
||||
func closesItsTurn(arm string) bool {
|
||||
return arm == armTaskCompleted || arm == armSkillCompleted || arm == armReadOnlySkillCompleted
|
||||
}
|
||||
|
||||
// loneTaskSentinels is what this arm's one prompt carries.
|
||||
func loneTaskSentinels(arm string) string {
|
||||
if queuedTaskArm(arm) {
|
||||
return taskHoldsFirst
|
||||
}
|
||||
return taskSentinel
|
||||
}
|
||||
|
||||
// runLoneTaskConstruct drives one delegation and dies at the moment this arm is
|
||||
// about. The completed arm is the exception that proves the rest: it lets the
|
||||
// turn close, so what it loses was never about dying inside one.
|
||||
func runLoneTaskConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
|
||||
// One turn, whatever the arm needs in it: a session admits no second one. The
|
||||
// queued arm's ceiling is filled by a background fleet named in the same
|
||||
// prompt, whose own child says when the slot is taken.
|
||||
prompt := fanOutTurn(turn+1, loneTaskSentinels(arm))
|
||||
switch {
|
||||
case arm == armTaskCompleted:
|
||||
if err := ctrl.Run(ctx, prompt); err != nil {
|
||||
return fmt.Errorf("delegating turn: %w", err)
|
||||
}
|
||||
case guardedArm(arm):
|
||||
// A stop belongs to whoever admitted the turn, so an arm that is going
|
||||
// to be stopped is admitted the way a person's input is.
|
||||
ctrl.Send(prompt)
|
||||
if err := waitForLoneTask(sink, prov, arm); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
go func() { _ = ctrl.Run(ctx, prompt) }()
|
||||
if err := waitForLoneTask(sink, prov, arm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
stop := ""
|
||||
if cancelTaskArm(arm) {
|
||||
stop = stopDelegation(ctrl, prov, arm)
|
||||
}
|
||||
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
|
||||
if stop != "" {
|
||||
obs.Progress[probeStopReached] = []string{stop}
|
||||
}
|
||||
if queuedTaskArm(arm) {
|
||||
// Whether the delegation's own child ever ran is the fact the live rows
|
||||
// are read against, and only the scripted provider knows it.
|
||||
obs.Progress[probeChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(childHang)))}
|
||||
}
|
||||
if err := writeObservation(root, obs); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopDelegation stops the work the way a person does — the turn's own cancel
|
||||
// for a foreground run, the job list and a kill for a backgrounded one — and
|
||||
// waits for the orchestration to let go of it. What each layer holds then is
|
||||
// this arm's whole question, so nothing is read until the record closes.
|
||||
func stopDelegation(ctrl *control.Controller, prov *scripted, arm string) string {
|
||||
stopped := time.Now()
|
||||
if backgroundTaskArm(arm) {
|
||||
for _, job := range ctrl.Jobs() {
|
||||
ctrl.CancelJob(job.ID)
|
||||
}
|
||||
} else {
|
||||
ctrl.Cancel()
|
||||
}
|
||||
// Wait for the record to close, but do not demand it: whether a stop reaches
|
||||
// the work it was aimed at is what this arm measures, and an arm that failed
|
||||
// when it did not would report the finding as its own premise breaking.
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for time.Now().Before(deadline) && !settledLone(ctrl) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
live := "gone"
|
||||
if prov.fleets.held(childEntered(childHang)) && !prov.fleets.held(childLeft(childHang)) {
|
||||
live = "still running"
|
||||
}
|
||||
return fmt.Sprintf("turn=%s work=%s after=%s", turnStanding(ctrl, arm), live,
|
||||
time.Since(stopped).Round(time.Second))
|
||||
}
|
||||
|
||||
func settledLone(ctrl *control.Controller) bool {
|
||||
for _, e := range control.ExecutionHistory(ctrl.SessionPath()) {
|
||||
if e.ID == parallelLoneNodeID && !e.SettledAt.IsZero() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// waitForLoneTask blocks until the delegation stands where this arm dies. The
|
||||
// foreground arms read the live graph; the background ones cannot — a handed-off
|
||||
// run publishes no node — so they read the progress the parent was told.
|
||||
func waitForLoneTask(sink *graphSink, prov *scripted, arm string) error {
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if loneTaskReady(sink, prov, arm) {
|
||||
// A refusal is a state nothing announces, so the arm holds still and
|
||||
// asks again: a child that was merely slow to start would have
|
||||
// arrived by now, and the death would be measuring a race.
|
||||
if beforeStartArm(arm) || arm == armTaskFgQueued {
|
||||
time.Sleep(2 * time.Second)
|
||||
return refusalHolding(prov, arm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
graph, _ := sink.snapshot()
|
||||
return errUnexpected("a lone delegation "+loneTaskWanted(arm),
|
||||
nodeStates(graph)+" progress="+fmt.Sprint(sink.phaseSeries()))
|
||||
}
|
||||
|
||||
// refusalHolding reports the arm's premise once more, after the settle: the
|
||||
// delegation's child must still not have run.
|
||||
func refusalHolding(prov *scripted, arm string) error {
|
||||
if prov.fleets.held(childEntered(childHang)) {
|
||||
return errUnexpected("a delegation the scheduler was still holding back", "its child ran")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loneTaskReady(sink *graphSink, prov *scripted, arm string) bool {
|
||||
graph, _ := sink.snapshot()
|
||||
switch arm {
|
||||
case armTaskFgQueued, armTaskFgQueuedCancel:
|
||||
// The node is drawn — the delegation reached the graph — and its child
|
||||
// has not started, which with the ceiling full is the refusal.
|
||||
return loneNodeDrawn(graph) && !prov.fleets.held(childEntered(childHang))
|
||||
case armTaskRunning, armTaskFgRunningCancel, armTaskBgRunningCancel:
|
||||
return holdsAll(graph, []agentgraph.NodeState{agentgraph.StateRunning})
|
||||
case armTaskBgQueued, armTaskBgQueuedCancel:
|
||||
phases := sink.phaseSeries()[taskCallID]
|
||||
return len(phases) > 0 && phases[len(phases)-1] == string(subagentQueued)
|
||||
case armTaskBgRunning:
|
||||
return slices.Contains(sink.phaseSeries()[taskCallID], subagentRunning)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// loneNodeDrawn reports whether the delegation's own node is on the graph.
|
||||
func loneNodeDrawn(g agentgraph.Graph) bool {
|
||||
for _, n := range g.Nodes {
|
||||
if n.Kind == agentgraph.KindWorker && n.ID == parallelLoneNodeID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parallelLoneNodeID is what a lone delegation's node is called: the parent
|
||||
// call, and the first slot of the fan-out numbering it shares.
|
||||
const parallelLoneNodeID = taskCallID + "/sub-1"
|
||||
|
||||
func loneTaskWanted(arm string) string {
|
||||
switch arm {
|
||||
case armTaskRunning, armTaskFgRunningCancel, armTaskBgRunningCancel:
|
||||
return "running"
|
||||
case armTaskFgQueued, armTaskFgQueuedCancel, armTaskBgQueuedCancel:
|
||||
return "drawn while the scheduler holds it back"
|
||||
case armTaskBgQueued:
|
||||
return "queued for a slot and not started"
|
||||
}
|
||||
return "running in its job"
|
||||
}
|
||||
|
||||
// The two phases the probe waits on. They are the parent-facing statuses a
|
||||
// background delegation emits, and for a handed-off run they are the only live
|
||||
// evidence there is.
|
||||
const (
|
||||
subagentQueued = "queued"
|
||||
subagentRunning = "running"
|
||||
)
|
||||
|
||||
// progressPhase reads one subagent status event. The tool id is the parent call
|
||||
// the delegation was made from, which is also what the graph node hangs under
|
||||
// and what the sub-agent store records as its parent — the join this arm exists
|
||||
// to check rather than assume.
|
||||
func progressPhase(e event.Event) (id, phase string, ok bool) {
|
||||
if e.Kind != event.ToolProgress || e.Tool.Name != event.SubagentProgressStatusName {
|
||||
return "", "", false
|
||||
}
|
||||
return e.Tool.ID, e.Tool.Output, e.Tool.ID != ""
|
||||
}
|
||||
@@ -1,536 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/agentgraph"
|
||||
"reasonix/internal/state/execgraph"
|
||||
"reasonix/internal/state/execjournal"
|
||||
)
|
||||
|
||||
// What a lone delegation leaves behind, layer by layer. The columns are the
|
||||
// ordinary ones: what the dying process held, and what the next one can still
|
||||
// say. Nothing here is a fix — the arm exists to find which transition stops
|
||||
// being recorded, because "single task has no graph" was the roadmap's guess
|
||||
// and the live chain has since been unified.
|
||||
func loneTaskRows(arm string, before, after Observation) []row {
|
||||
return []row{
|
||||
liveDelegationRow(before),
|
||||
parentStatusRow(before),
|
||||
durableOpeningRow(before, after),
|
||||
durableQueueRow(before, after),
|
||||
durableStartRow(before, after),
|
||||
durableSettleRow(before, after),
|
||||
delegationStoreRow(before, after),
|
||||
rebuiltDelegationRow(before, after),
|
||||
rebuiltDelegationIdentityRow(after),
|
||||
delegationInterruptionRow(before, after),
|
||||
identityJoinRow(before),
|
||||
heldBackRow(arm, before),
|
||||
stoppedOwnershipRow(arm, before),
|
||||
startedTerminalRow(before, after),
|
||||
fanOutControlRow(before),
|
||||
}
|
||||
}
|
||||
|
||||
// ofLoneTask reports whether an id belongs to the delegation under measurement.
|
||||
// The queued arm fills its ceiling with a fan-out, and a fan-out records
|
||||
// everything — so without this the rows would read that record as the lone
|
||||
// task's own and report a gap as closed.
|
||||
func ofLoneTask(id string) bool {
|
||||
return id == taskCallID || strings.HasPrefix(id, taskCallID+"/")
|
||||
}
|
||||
|
||||
const (
|
||||
crossAuthority = "the journal and the store, read against each other"
|
||||
liveAuthority = "event sink (GraphDelta), in-process"
|
||||
journalAuthority = "execjournal, written by the orchestration"
|
||||
storeAuthority = "sub-agent store"
|
||||
rebuildAuthority = "execgraph.Rebuild over journal + store"
|
||||
)
|
||||
|
||||
// liveDelegationRow is what the dying process actually drew. A backgrounded run
|
||||
// draws nothing at all — openDelegationGraph returns early for it — so this row
|
||||
// separates "the graph was lost" from "there was never a node".
|
||||
func liveDelegationRow(before Observation) row {
|
||||
var drawn []string
|
||||
for _, n := range before.Graph.Nodes {
|
||||
if n.Kind == agentgraph.KindWorker && ofLoneTask(n.ID) {
|
||||
drawn = append(drawn, n.ID+":"+string(n.State))
|
||||
}
|
||||
}
|
||||
sort.Strings(drawn)
|
||||
return row{
|
||||
Semantic: "the delegation the live graph drew", Authority: liveAuthority,
|
||||
Artifact: "none observed", Reconstruction: "in-process only",
|
||||
Before: orNone(join(drawn)), After: "—", Verdict: liveVerdict(len(drawn) > 0),
|
||||
}
|
||||
}
|
||||
|
||||
func liveVerdict(drawn bool) string {
|
||||
if drawn {
|
||||
return verdictStable
|
||||
}
|
||||
return verdictNotMeasured
|
||||
}
|
||||
|
||||
// parentStatusRow is the other live surface, and the only one a handed-off run
|
||||
// has: the statuses the parent tool call was told.
|
||||
func parentStatusRow(before Observation) row {
|
||||
var told []string
|
||||
for id, phases := range before.Progress {
|
||||
if ofLoneTask(id) {
|
||||
told = append(told, id+":"+strings.Join(phases, "→"))
|
||||
}
|
||||
}
|
||||
sort.Strings(told)
|
||||
return row{
|
||||
Semantic: "what the parent call was told", Authority: "subagent progress (ToolProgress)",
|
||||
Artifact: "none observed", Reconstruction: "in-process only",
|
||||
Before: orNone(join(told)), After: "—", Verdict: liveVerdict(len(told) > 0),
|
||||
}
|
||||
}
|
||||
|
||||
func durableOpeningRow(before, after Observation) row {
|
||||
return valueRow("durable opening", journalAuthority, "<stem>.execution.jsonl",
|
||||
"read back by ExecutionHistory", executionIDs(before), executionIDs(after), true)
|
||||
}
|
||||
|
||||
func durableQueueRow(before, after Observation) row {
|
||||
return valueRow("durable queue refusal", journalAuthority, "<stem>.execution.jsonl",
|
||||
"the scheduler's first refusal, with its cause",
|
||||
queuedCauses(before), queuedCauses(after), true)
|
||||
}
|
||||
|
||||
func durableStartRow(before, after Observation) row {
|
||||
return valueRow("durable slot grant", journalAuthority, "<stem>.execution.jsonl",
|
||||
"written before the child can act", startedIDs(before), startedIDs(after), true)
|
||||
}
|
||||
|
||||
func durableSettleRow(before, after Observation) row {
|
||||
return valueRow("durable settlement", journalAuthority, "<stem>.execution.jsonl",
|
||||
"the orchestration letting go of the execution",
|
||||
settledIDs(before), settledIDs(after), true)
|
||||
}
|
||||
|
||||
// delegationStoreRow is the layer that already survives for a fan-out item. It
|
||||
// is the reason this arm cannot be read off the graph alone: a store record with
|
||||
// no opening leaves the run's grant, worker and scheduling unaccounted for.
|
||||
func delegationStoreRow(before, after Observation) row {
|
||||
return valueRow("child the store kept", storeAuthority, "subagents/<ref>.meta.json",
|
||||
"ListSubagentsByParent over the transcript stem",
|
||||
childFacts(before), childFacts(after), true)
|
||||
}
|
||||
|
||||
func rebuiltDelegationRow(before, after Observation) row {
|
||||
rebuilt := rebuiltGraph(after)
|
||||
var nodes []string
|
||||
for _, n := range rebuilt.Graph.Nodes {
|
||||
if n.Kind != agentgraph.KindGroup && ofLoneTask(n.ID) {
|
||||
nodes = append(nodes, n.ID+":"+string(n.State))
|
||||
}
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
return valueRow("the node a restart rebuilds", rebuildAuthority, "<stem>.execution.jsonl",
|
||||
"execgraph.Rebuild", loneDelegationAtDeath(before), join(nodes), false)
|
||||
}
|
||||
|
||||
// rebuiltDelegationIdentityRow is what a reader sees on the rebuilt node beyond
|
||||
// its state. Grant and worker are opening facts: the store cannot answer them,
|
||||
// so an arm that only compared states would call a hollow node exact.
|
||||
func rebuiltDelegationIdentityRow(after Observation) row {
|
||||
var got []string
|
||||
for _, n := range rebuiltGraph(after).Graph.Nodes {
|
||||
if n.Kind == agentgraph.KindGroup || !ofLoneTask(n.ID) {
|
||||
continue
|
||||
}
|
||||
got = append(got, fmt.Sprintf("%s grant=%s model=%s effort=%s wait=%s",
|
||||
n.ID, orDash(string(n.Grant)), orDash(n.Model), orDash(n.Effort), orDash(string(n.Wait))))
|
||||
}
|
||||
sort.Strings(got)
|
||||
return row{
|
||||
Semantic: "grant, worker and wait on the rebuilt node", Authority: rebuildAuthority,
|
||||
Artifact: "<stem>.execution.jsonl", Reconstruction: "opening facts, which no store record carries",
|
||||
Before: "declared at the opening", After: orNone(join(got)),
|
||||
Verdict: rebuiltIdentityVerdict(got),
|
||||
}
|
||||
}
|
||||
|
||||
func rebuiltIdentityVerdict(got []string) string {
|
||||
if len(got) == 0 {
|
||||
return verdictLost
|
||||
}
|
||||
return verdictExact
|
||||
}
|
||||
|
||||
func delegationInterruptionRow(before, after Observation) row {
|
||||
var got []string
|
||||
for _, i := range rebuiltGraph(after).Interrupted {
|
||||
if ofLoneTask(i.Execution) {
|
||||
got = append(got, i.Execution+":"+interruptionKind(i))
|
||||
}
|
||||
}
|
||||
sort.Strings(got)
|
||||
return valueRow("the interruption a restart names", rebuildAuthority, "<stem>.execution.jsonl",
|
||||
"an execution with no owner is named, never redrawn as running",
|
||||
loneDelegationAtDeath(before), join(got), false)
|
||||
}
|
||||
|
||||
// loneDelegationAtDeath is the delegation as the dying process last knew it,
|
||||
// from whichever surface knew: a foreground run has a node, a handed-off one
|
||||
// has only the store's record, and an arm whose rows read the wrong surface
|
||||
// would report a loss the other one never had.
|
||||
func loneDelegationAtDeath(o Observation) string {
|
||||
if live := liveWorkers(o); live != "" {
|
||||
return live
|
||||
}
|
||||
return childFacts(o)
|
||||
}
|
||||
|
||||
func interruptionKind(i execgraph.Interruption) string {
|
||||
if i.Started {
|
||||
return execjournal.InterruptedDuringExecution
|
||||
}
|
||||
return execjournal.InterruptedBeforeStart
|
||||
}
|
||||
|
||||
// identityJoinRow is the question that decides whether Step 8 needs an identity
|
||||
// at all: the live node, the call it hung under, the parent the store recorded,
|
||||
// and the execution the journal opened. Four spellings, or one identity.
|
||||
func identityJoinRow(before Observation) row {
|
||||
live, call, stored, opened := identitiesAtDeath(before)
|
||||
verdict := verdictViolated
|
||||
switch {
|
||||
case stored != "" && (stored == opened || stored == call || strings.HasPrefix(live, stored)):
|
||||
verdict = verdictHolds
|
||||
case stored == "" && !startedAtDeath(before):
|
||||
// Nothing executed, so the store owes no record and there is no join to
|
||||
// make. A missing artifact here is the arm's own premise, not a defect.
|
||||
verdict = verdictNotMeasured
|
||||
}
|
||||
return row{
|
||||
Semantic: "one identity or four", Authority: "the graph, the call, the store and the journal",
|
||||
Artifact: "subagents/<ref>.meta.json, <stem>.execution.jsonl",
|
||||
Reconstruction: "the store's parent call is what a rebuild joins on",
|
||||
Before: fmt.Sprintf("node=%s call=%s", orDash(live), orDash(call)),
|
||||
After: fmt.Sprintf("store parent=%s journal=%s", orDash(stored), orDash(opened)),
|
||||
Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
// identitiesAtDeath reads the four spellings out of the dying process's own
|
||||
// observation, so a join that only holds after a restart cannot be claimed.
|
||||
func identitiesAtDeath(o Observation) (live, call, stored, opened string) {
|
||||
for _, n := range o.Graph.Nodes {
|
||||
if n.Kind == agentgraph.KindWorker && ofLoneTask(n.ID) {
|
||||
live, call = n.ID, n.ParentID
|
||||
break
|
||||
}
|
||||
}
|
||||
for id := range o.Progress {
|
||||
if call == "" && ofLoneTask(id) {
|
||||
call = id
|
||||
}
|
||||
}
|
||||
for _, f := range o.Children.Facts {
|
||||
if ofLoneTask(f.ParentToolCallID) {
|
||||
stored = f.ParentToolCallID
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, e := range o.Executions {
|
||||
if ofLoneTask(e.ID) {
|
||||
opened = e.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
return live, call, stored, opened
|
||||
}
|
||||
|
||||
// startedAtDeath reports whether the delegation was ever granted a slot.
|
||||
func startedAtDeath(o Observation) bool {
|
||||
for _, e := range o.Executions {
|
||||
if ofLoneTask(e.ID) && e.Started() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func executionIDs(o Observation) string {
|
||||
var out []string
|
||||
for _, e := range o.Executions {
|
||||
if ofLoneTask(e.ID) {
|
||||
out = append(out, e.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func queuedCauses(o Observation) string {
|
||||
var out []string
|
||||
for _, e := range o.Executions {
|
||||
if e.Queued() && ofLoneTask(e.ID) {
|
||||
out = append(out, e.ID+":"+orDash(e.Cause))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func startedIDs(o Observation) string {
|
||||
var out []string
|
||||
for _, e := range o.Executions {
|
||||
if e.Started() && ofLoneTask(e.ID) {
|
||||
out = append(out, e.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func settledIDs(o Observation) string {
|
||||
var out []string
|
||||
for _, e := range o.Executions {
|
||||
if !e.SettledAt.IsZero() && ofLoneTask(e.ID) {
|
||||
out = append(out, e.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func childFacts(o Observation) string {
|
||||
var out []string
|
||||
for _, f := range o.Children.Facts {
|
||||
if ofLoneTask(f.ParentToolCallID) {
|
||||
out = append(out, f.ParentToolCallID+":"+f.Status)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func liveWorkers(o Observation) string {
|
||||
var out []string
|
||||
for _, n := range o.Graph.Nodes {
|
||||
if n.Kind == agentgraph.KindWorker && ofLoneTask(n.ID) {
|
||||
out = append(out, n.ID+":"+string(n.State))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func orDash(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// heldBackRow is the live-truth row: with the ceiling full and the delegation's
|
||||
// own child never reaching the provider, the scheduler is holding it back — and
|
||||
// what the picture says while that is true is a claim about now, which no
|
||||
// restart can correct. Running is the answer that is wrong.
|
||||
func heldBackRow(arm string, before Observation) row {
|
||||
if !queuedTaskArm(arm) {
|
||||
return row{
|
||||
Semantic: "what the picture says while the scheduler holds it back", Authority: liveAuthority,
|
||||
Artifact: "none observed", Reconstruction: "this arm never fills the ceiling",
|
||||
Before: "—", After: "—", Verdict: verdictNotMeasured,
|
||||
}
|
||||
}
|
||||
drawn := orNone(liveWorkers(before))
|
||||
told := orNone(loneStatus(before))
|
||||
verdict := verdictHolds
|
||||
if strings.Contains(drawn, ":running") || told == subagentRunning {
|
||||
verdict = verdictViolated
|
||||
}
|
||||
return row{
|
||||
Semantic: "what the picture says while the scheduler holds it back", Authority: liveAuthority,
|
||||
Artifact: "none observed",
|
||||
Reconstruction: "queued with its cause, or at least not started",
|
||||
Before: "held back: ceiling full, child never entered",
|
||||
After: "graph=" + drawn + " parent=" + told, Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
func loneStatus(o Observation) string {
|
||||
for id, phases := range o.Progress {
|
||||
if ofLoneTask(id) && len(phases) > 0 {
|
||||
return phases[len(phases)-1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stoppedOwnershipRow is the invariant a stop has to leave behind: once the
|
||||
// turn is over, nothing it owned may still be open with a live owner. A record
|
||||
// left open is what a restart reads as an interruption, so a turn that merely
|
||||
// stopped waiting looks, afterwards, exactly like a process that died.
|
||||
func stoppedOwnershipRow(arm string, before Observation) row {
|
||||
if !cancelTaskArm(arm) {
|
||||
return row{
|
||||
Semantic: "work the stopped turn still owns", Authority: crossAuthority,
|
||||
Artifact: "<stem>.execution.jsonl", Reconstruction: "this arm stops nothing",
|
||||
Before: "—", After: "—", Verdict: verdictNotMeasured,
|
||||
}
|
||||
}
|
||||
reading := ""
|
||||
if seen := before.Progress[probeStopReached]; len(seen) > 0 {
|
||||
reading = seen[0]
|
||||
}
|
||||
entry, _ := loneEntry(before)
|
||||
open := entry.SettledAt.IsZero()
|
||||
turnOver := strings.Contains(reading, "turn=ended")
|
||||
// An open record implies a live owner: nothing else closes one. The child's
|
||||
// liveness rides in the reading as evidence, not as the test — a delegation
|
||||
// still waiting for a slot has no child and is just as much still owned.
|
||||
return row{
|
||||
Semantic: "work the stopped turn still owns", Authority: crossAuthority,
|
||||
Artifact: "<stem>.execution.jsonl",
|
||||
Reconstruction: "a turn that has ended owns nothing still open",
|
||||
Before: "the turn owned it when the stop was issued",
|
||||
After: orNone(reading) + " record=" + entryStanding(entry), Verdict: held(!(turnOver && open)),
|
||||
}
|
||||
}
|
||||
|
||||
func entryStanding(e execjournal.Entry) string {
|
||||
if e.SettledAt.IsZero() {
|
||||
return "still open"
|
||||
}
|
||||
return "settled"
|
||||
}
|
||||
|
||||
// startedTerminalRow refuses the combination neither layer may produce: a child
|
||||
// record for work the journal proves was never admitted. A state comparison
|
||||
// shows that only as "cancelled came back failed"; this names the cause, which
|
||||
// is a store asked to speak for an execution that never happened.
|
||||
func startedTerminalRow(before, after Observation) row {
|
||||
if startedAtDeath(before) {
|
||||
return row{
|
||||
Semantic: "a child record for work that never started", Authority: crossAuthority,
|
||||
Artifact: "subagents/<ref>.meta.json, <stem>.execution.jsonl",
|
||||
Reconstruction: "this delegation was granted a slot, so the store owes a record",
|
||||
Before: "—", After: "—", Verdict: verdictNotMeasured,
|
||||
}
|
||||
}
|
||||
kept := orNone(join(compact(childFacts(before), childFacts(after))))
|
||||
return row{
|
||||
Semantic: "a child record for work that never started", Authority: crossAuthority,
|
||||
Artifact: "subagents/<ref>.meta.json, <stem>.execution.jsonl",
|
||||
Reconstruction: "no slot was ever granted, so nothing ran and nothing owes a terminal",
|
||||
Before: "none", After: kept, Verdict: held(kept == "none"),
|
||||
}
|
||||
}
|
||||
|
||||
func compact(parts ...string) []string {
|
||||
var out []string
|
||||
for _, p := range parts {
|
||||
if strings.TrimSpace(p) != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fanOutControlRow is the positive control the queued arm carries with it: the
|
||||
// fleet that fills its ceiling runs through the same scheduler in the same
|
||||
// session, and records everything. What this arm reports missing is missing at
|
||||
// the entry point, not in the machinery.
|
||||
func fanOutControlRow(before Observation) row {
|
||||
var recorded []string
|
||||
for _, e := range before.Executions {
|
||||
if !ofLoneTask(e.ID) {
|
||||
recorded = append(recorded, e.ID+lifecycleOf(e))
|
||||
}
|
||||
}
|
||||
sort.Strings(recorded)
|
||||
verdict := verdictNotMeasured
|
||||
if len(recorded) > 0 {
|
||||
verdict = verdictPersisted
|
||||
}
|
||||
return row{
|
||||
Semantic: "a fan-out in the same session, same scheduler", Authority: journalAuthority,
|
||||
Artifact: "<stem>.execution.jsonl", Reconstruction: "control: the machinery this arm reports missing",
|
||||
Before: orNone(join(recorded)), After: "—", Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
func lifecycleOf(e execjournal.Entry) string {
|
||||
marks := ""
|
||||
if e.Queued() {
|
||||
marks += " queued=" + orDash(e.Cause)
|
||||
}
|
||||
if e.Started() {
|
||||
marks += " started"
|
||||
}
|
||||
if !e.SettledAt.IsZero() {
|
||||
marks += " settled"
|
||||
}
|
||||
return marks
|
||||
}
|
||||
|
||||
// loneTaskArmInvalid guards each arm's premise: the state it is built to die in
|
||||
// has to have been reached, or its rows describe a different death.
|
||||
func loneTaskArmInvalid(arm string, before Observation) string {
|
||||
phases := strings.Join(before.Progress[taskCallID], "→")
|
||||
switch arm {
|
||||
case armTaskCompleted:
|
||||
if !strings.Contains(phases, "completed") && len(before.Children.Facts) == 0 {
|
||||
return "the delegation never completed, so this arm measures a different death"
|
||||
}
|
||||
case armTaskRunning:
|
||||
if !holdsState(before, string(agentgraph.StateRunning)) {
|
||||
return "nothing was executing when the process died"
|
||||
}
|
||||
case armTaskFgQueued:
|
||||
if entered := before.Progress[probeChildEntered]; len(entered) == 0 || entered[0] != "false" {
|
||||
return "the delegation's child ran, so the scheduler was not holding it back"
|
||||
}
|
||||
if !strings.Contains(fanOutControlRow(before).Before, "queued=slots") {
|
||||
return "no fan-out filled the ceiling, so nothing refused the delegation"
|
||||
}
|
||||
case armTaskBgQueued:
|
||||
if !strings.HasSuffix(phases, subagentQueued) {
|
||||
return "the delegation was not left queued for a slot: " + orNone(phases)
|
||||
}
|
||||
case armTaskBgRunning:
|
||||
if !strings.Contains(phases, subagentRunning) {
|
||||
return "the delegation never entered its job: " + orNone(phases)
|
||||
}
|
||||
}
|
||||
if cancelTaskArm(arm) {
|
||||
return stoppedArmInvalid(arm, before)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stoppedArmInvalid holds a cancellation arm to the side of the boundary it was
|
||||
// built for. An arm that stopped the work somewhere else measures a different
|
||||
// ownership question and must say so rather than reporting the answer.
|
||||
func stoppedArmInvalid(arm string, before Observation) string {
|
||||
entry, ok := loneEntry(before)
|
||||
switch {
|
||||
case !ok:
|
||||
return "the delegation was never recorded, so there is no ownership to read"
|
||||
case beforeStartArm(arm) && entry.Started():
|
||||
return "the delegation was granted a slot before the stop, so this is not a pre-start cancellation"
|
||||
case !beforeStartArm(arm) && !entry.Started():
|
||||
return "the delegation never held a slot, so this is not a cancellation of work that ran"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loneEntry(o Observation) (execjournal.Entry, bool) {
|
||||
for _, e := range o.Executions {
|
||||
if ofLoneTask(e.ID) {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
return execjournal.Entry{}, false
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// Process-boundary resurrection probe. It answers one question and modifies
|
||||
// nothing: after the process that built a session exits, what can a new process
|
||||
// still prove about that session's runtime semantics without asking a model?
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
phase := flag.String("phase", "", "internal: construct|successor|resume, spawned by the orchestrator")
|
||||
root := flag.String("root", "", "internal: this arm's root directory")
|
||||
armName := flag.String("arm", "", "internal: this arm's name")
|
||||
work := flag.String("work", "", "directory to hold arm roots; default is a temp dir")
|
||||
only := flag.String("only", "", "run a single arm by name")
|
||||
jsonOut := flag.String("json", "", "also write the machine-readable matrix here")
|
||||
keep := flag.Bool("keep", false, "keep arm roots on disk after the run")
|
||||
sabotage := flag.String("sabotage", "", "run under a deliberate defect the arm must fail on (ui-graph-mixed: publish|trajectory)")
|
||||
flag.Parse()
|
||||
if *sabotage != "" {
|
||||
// Carried to the children through the environment they already inherit,
|
||||
// so no phase has to learn about a flag it does not read.
|
||||
if err := os.Setenv(envProbeSabotage, *sabotage); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "runtime-resume:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
switch *phase {
|
||||
case "construct":
|
||||
err = runConstruct(*root, *armName)
|
||||
case "successor":
|
||||
err = runSuccessor(*root, *armName)
|
||||
case "resume":
|
||||
err = runResume(*root, *armName)
|
||||
case "":
|
||||
err = orchestrate(*work, *only, *jsonOut, *keep)
|
||||
default:
|
||||
err = fmt.Errorf("unknown phase %q", *phase)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "runtime-resume:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,584 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/agentgraph"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/runtime/delegation"
|
||||
"reasonix/internal/safety/evidence"
|
||||
"reasonix/internal/session/control"
|
||||
"reasonix/internal/state/execjournal"
|
||||
"reasonix/internal/state/store"
|
||||
)
|
||||
|
||||
// Observation is everything one phase can read about a session without asking a
|
||||
// model. Both phases capture the same shape so the matrix is a comparison and
|
||||
// not two differently-shaped reports.
|
||||
type Observation struct {
|
||||
Phase string `json:"phase"`
|
||||
Arm string `json:"arm"`
|
||||
SessionPath string `json:"session_path"`
|
||||
SystemHash string `json:"system_hash"`
|
||||
SystemBytes int `json:"system_bytes"`
|
||||
// BootSystemHash is what this process's assembly composed, read before any
|
||||
// resume; SystemHash is the session's own leading row. Different questions.
|
||||
BootSystemHash string `json:"boot_system_hash"`
|
||||
BootSystemBytes int `json:"boot_system_bytes"`
|
||||
Transcript TranscriptObs `json:"transcript"`
|
||||
Goal GoalObs `json:"goal"`
|
||||
Todos []evidence.TodoItem `json:"todos"`
|
||||
Decisions []control.Decision `json:"decisions"`
|
||||
Context ContextObs `json:"context"`
|
||||
Sidecar SidecarObs `json:"sidecar"`
|
||||
View ViewObs `json:"view"`
|
||||
TodoNotes TodoNoteObs `json:"todo_notes"`
|
||||
Deferred DeferredObs `json:"deferred"`
|
||||
Obligation ObligationObs `json:"obligation"`
|
||||
// Interrupted is what the host derives from a barrier it found open and did
|
||||
// not open itself: evidence the wait happened, not a question to answer.
|
||||
Interrupted []control.InterruptedAdjudication `json:"interrupted"`
|
||||
// ModelSeesInterruption counts the interruption blocks the request carries,
|
||||
// which is a different surface from the derived fact behind them.
|
||||
ModelSeesInterruption int `json:"model_sees_interruption"`
|
||||
// Journal is how each barrier ended. A superseded edge is the durable
|
||||
// evidence that a turn received the interruption: nothing else writes one.
|
||||
Journal []control.AdjudicationEntry `json:"journal,omitempty"`
|
||||
Graph GraphObs `json:"graph"`
|
||||
// Children is the durable side of the same fan-out the graph draws. The
|
||||
// graph has no read surface after a restart, so without this a lost graph
|
||||
// and a lost delegation would be one indistinguishable row.
|
||||
Children ChildrenObs `json:"children"`
|
||||
// Listing is the same children through the read list_subagents performs.
|
||||
// It filters by workspace and walks lineage, so it can answer differently
|
||||
// from the one above — and it is the answer a model asking would be shown.
|
||||
Listing ListingObs `json:"listing"`
|
||||
// FanOutTurn is whether the dispatching turn is in this phase's transcript
|
||||
// at all. A turn is appended when it ends, so a process that dies inside one
|
||||
// leaves no record of the request, and a lost graph is the smaller half.
|
||||
FanOutTurn bool `json:"fan_out_turn"`
|
||||
// Executions is the durable record of delegations this turn opened, and
|
||||
// InterruptedExecutions the host's judgement over it: open, with no owner
|
||||
// here. Written before the work starts, so it outlives an unfinished turn.
|
||||
Executions []execjournal.Entry `json:"executions,omitempty"`
|
||||
InterruptedExecutions []execjournal.Entry `json:"interrupted_executions,omitempty"`
|
||||
// ModelSeesInterruptedExecution counts the blocks the next request carries
|
||||
// about them, which is a different surface from the fact behind them.
|
||||
ModelSeesInterruptedExecution int `json:"model_sees_interrupted_execution"`
|
||||
// WaitCauses counts the refusals the graph carries, and WaitSeries keeps
|
||||
// each node's causes in publication order. The fold keeps only the latest,
|
||||
// which cannot say whether a cause was reported once or replaced.
|
||||
WaitCauses map[string]int `json:"wait_causes,omitempty"`
|
||||
WaitSeries map[string][]string `json:"wait_series,omitempty"`
|
||||
// Progress is every parent-facing subagent status, per call. A background
|
||||
// delegation draws no node, so this is where its live evidence is.
|
||||
Progress map[string][]string `json:"progress,omitempty"`
|
||||
// SettledWorkers and QueuedWorkers are the transition arm's evidence that
|
||||
// capacity was actually freed while a refusal still stood.
|
||||
SettledWorkers int `json:"settled_workers"`
|
||||
QueuedWorkers []string `json:"queued_workers,omitempty"`
|
||||
Artifacts []ArtifactObs `json:"artifacts"`
|
||||
// UI is what a frontend attached to this process saw, in order. Only the one
|
||||
// arm that runs a client fills it; a value comparison cannot answer what it
|
||||
// asks, which is through which door each fact arrived.
|
||||
UI *UIObs `json:"ui,omitempty"`
|
||||
}
|
||||
|
||||
type TranscriptObs struct {
|
||||
Messages int `json:"messages"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
type GoalObs struct {
|
||||
Goal string `json:"goal"`
|
||||
Status string `json:"status"`
|
||||
TurnsUsed int `json:"turns_used"`
|
||||
}
|
||||
|
||||
// ContextObs is the live judgement: CheckpointState is "none" whenever the
|
||||
// projection did not survive validation, which is what the compaction arms are
|
||||
// actually asking about.
|
||||
type ContextObs struct {
|
||||
ProjectionVersion uint64 `json:"projection_version"`
|
||||
CheckpointState string `json:"checkpoint_state"`
|
||||
CanonicalTokens int `json:"canonical_tokens"`
|
||||
ProjectedTokens int `json:"projected_tokens"`
|
||||
Blocked bool `json:"blocked"`
|
||||
}
|
||||
|
||||
// SidecarObs is the stored claim, read off disk rather than through the agent,
|
||||
// so a projection the host refuses to use is still visible as present.
|
||||
type SidecarObs struct {
|
||||
Present bool `json:"present"`
|
||||
Messages int `json:"messages"`
|
||||
CoveredCount int `json:"covered_count"`
|
||||
CoveredPrefixHash string `json:"covered_prefix_hash"`
|
||||
ProjectionVersion uint64 `json:"projection_version"`
|
||||
TranscriptVersion uint64 `json:"transcript_version"`
|
||||
Generation uint64 `json:"generation"`
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
// ViewObs is the model-visible context, spliced the way ContextProjection
|
||||
// declares it: the stored body followed by canonical[CoveredCount:]. Markers
|
||||
// are what a fold can be held to — the scripted digest carries none, so a
|
||||
// marker that is gone was dropped, not summarised.
|
||||
type ViewObs struct {
|
||||
Messages int `json:"messages"`
|
||||
// Markers are the user turns still shown, Echoes the assistant replies. A
|
||||
// fold takes the reply first, so the two answer different questions.
|
||||
Markers []string `json:"markers"`
|
||||
Echoes []string `json:"echoes"`
|
||||
BodyMarkers []string `json:"body_markers"`
|
||||
BodyEchoes []string `json:"body_echoes"`
|
||||
SplicedFromTail int `json:"spliced_from_tail"`
|
||||
}
|
||||
|
||||
// TodoNoteObs locates the host's step-identity note inside the model-visible
|
||||
// view. BodyLen is where the frozen body ends, so an index below it says the
|
||||
// note was frozen ahead of the live tail rather than riding it.
|
||||
type TodoNoteObs struct {
|
||||
Count int `json:"count"`
|
||||
Indexes []int `json:"indexes"`
|
||||
IDs []string `json:"ids"`
|
||||
BodyLen int `json:"body_len"`
|
||||
ViewLen int `json:"view_len"`
|
||||
HostIDs []string `json:"host_ids"`
|
||||
// ReadableInHistory says the conversation still shows every current id, so
|
||||
// no note is owed. Without it a count of zero cannot be told apart from a
|
||||
// lost identity, and the gate would demand a note nobody needs.
|
||||
ReadableInHistory bool `json:"readable_in_history"`
|
||||
}
|
||||
|
||||
// todoNoteMarker is the opening of todoIdentityNote. The probe reads the view
|
||||
// the way a model would, so it matches the text rather than a type.
|
||||
const todoNoteMarker = "Host task state."
|
||||
|
||||
var stepIDPattern = regexp.MustCompile(`probe_step_\d+`)
|
||||
|
||||
func todoNoteObs(view, history []provider.Message, todos []evidence.TodoItem) TodoNoteObs {
|
||||
out := TodoNoteObs{BodyLen: len(history), ViewLen: len(view)}
|
||||
for _, t := range todos {
|
||||
if t.StepID != "" {
|
||||
out.HostIDs = append(out.HostIDs, t.StepID)
|
||||
}
|
||||
}
|
||||
out.ReadableInHistory = len(out.HostIDs) > 0
|
||||
for _, id := range out.HostIDs {
|
||||
if !mentionsID(history, id) {
|
||||
out.ReadableInHistory = false
|
||||
}
|
||||
}
|
||||
for i, m := range view {
|
||||
if !strings.Contains(m.Content, todoNoteMarker) {
|
||||
continue
|
||||
}
|
||||
out.Count++
|
||||
out.Indexes = append(out.Indexes, i)
|
||||
out.IDs = append(out.IDs, stepIDPattern.FindAllString(m.Content, -1)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DeferredObs is the effect a pending decision is holding back. Whether the
|
||||
// decision itself survives is one question; whether what it blocked stayed
|
||||
// blocked is the one that cannot be answered by a design choice.
|
||||
type DeferredObs struct {
|
||||
MarkerPath string `json:"marker_path"`
|
||||
Executed bool `json:"executed"`
|
||||
}
|
||||
|
||||
func deferredObs(workspace string) DeferredObs {
|
||||
path := filepath.Join(workspace, deferredEffect)
|
||||
_, err := os.Stat(path)
|
||||
return DeferredObs{MarkerPath: deferredEffect, Executed: err == nil}
|
||||
}
|
||||
|
||||
// ObligationObs is what the transcript itself still says about a turn that
|
||||
// never finished: a tool call with no result is a claim the host made and did
|
||||
// not settle, and an interruption record is the host saying so outright. Either
|
||||
// tells a lost decision apart from a silently dropped one.
|
||||
type ObligationObs struct {
|
||||
UnansweredCalls []string `json:"unanswered_calls"`
|
||||
InterruptionMarked int `json:"interruption_marked"`
|
||||
}
|
||||
|
||||
func obligationObs(msgs []provider.Message) ObligationObs {
|
||||
answered := map[string]bool{}
|
||||
var out ObligationObs
|
||||
for _, m := range msgs {
|
||||
if m.ToolCallID != "" {
|
||||
answered[m.ToolCallID] = true
|
||||
}
|
||||
if m.InterruptedTurn != nil {
|
||||
out.InterruptionMarked++
|
||||
}
|
||||
}
|
||||
for _, m := range msgs {
|
||||
for _, call := range m.ToolCalls {
|
||||
if call.ID != "" && call.ID != provider.LocalOnlyToolID && !answered[call.ID] {
|
||||
out.UnansweredCalls = append(out.UnansweredCalls, call.Name+":"+call.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type GraphObs struct {
|
||||
Deltas int `json:"deltas"`
|
||||
Nodes []agentgraph.Node `json:"nodes,omitempty"`
|
||||
Edges []agentgraph.Edge `json:"edges,omitempty"`
|
||||
Grants map[string]string `json:"grants,omitempty"`
|
||||
Waits map[string]string `json:"waits,omitempty"`
|
||||
}
|
||||
|
||||
// ChildFact is one delegated run as the durable store records it, which is a
|
||||
// different set of facts from the node the graph drew for it. Whatever the
|
||||
// graph carried and this does not is a semantic no reconstruction can recover.
|
||||
type ChildFact struct {
|
||||
Ref string `json:"ref"`
|
||||
Status string `json:"status"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
// ExecutionID is which execution the record belongs to; ParentToolCallID is
|
||||
// the provider-visible call it descends from. They are separate answers, and
|
||||
// a host-started run has only the first.
|
||||
ExecutionID string `json:"executionId,omitempty"`
|
||||
ParentToolCallID string `json:"parentToolCallId,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Effort string `json:"effort,omitempty"`
|
||||
}
|
||||
|
||||
// ChildrenObs is every child the store still owns for this parent. It is the
|
||||
// only durable execution evidence a restart has been shown to read, so a graph
|
||||
// classification that ignores it would report a loss the host can in fact
|
||||
// still speak to.
|
||||
type ChildrenObs struct {
|
||||
Facts []ChildFact `json:"facts,omitempty"`
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
// childrenObs reads the store the way a restart does: by parent session id,
|
||||
// which is the transcript's stem. A session with no path owns nothing.
|
||||
func childrenObs(root armRoot, sessionPath string) ChildrenObs {
|
||||
if strings.TrimSpace(sessionPath) == "" {
|
||||
return ChildrenObs{}
|
||||
}
|
||||
parent := strings.TrimSuffix(filepath.Base(sessionPath), ".jsonl")
|
||||
artifacts, err := sessionstore.ListSubagentsByParent(root.Sessions, parent)
|
||||
if err != nil {
|
||||
return ChildrenObs{Err: err.Error()}
|
||||
}
|
||||
out := ChildrenObs{}
|
||||
for _, a := range artifacts {
|
||||
out.Facts = append(out.Facts, ChildFact{
|
||||
Ref: a.Ref, Status: string(a.Meta.Status), Kind: a.Meta.Kind, Name: a.Meta.Name,
|
||||
ExecutionID: a.Meta.ExecutionID,
|
||||
ParentToolCallID: a.Meta.ParentToolCallID, Model: a.Meta.Model, Effort: a.Meta.Effort,
|
||||
})
|
||||
}
|
||||
sort.Slice(out.Facts, func(i, j int) bool { return out.Facts[i].Ref < out.Facts[j].Ref })
|
||||
return out
|
||||
}
|
||||
|
||||
// ListingRow is one row list_subagents would print. The tool call itself needs
|
||||
// a registry this probe has no way to build, so the read behind it is what is
|
||||
// exercised; the formatting adds no fact the row does not already carry.
|
||||
type ListingRow struct {
|
||||
Ref string `json:"ref"`
|
||||
Status string `json:"status"`
|
||||
From string `json:"from,omitempty"`
|
||||
}
|
||||
|
||||
// ListingObs is what the recovery surface would show for this conversation.
|
||||
type ListingObs struct {
|
||||
Rows []ListingRow `json:"rows,omitempty"`
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
// listingObs reads the store the way the tool does: by the parent session the
|
||||
// child recorded, filtered to this workspace. The workspace filter is why this
|
||||
// cannot be folded into childrenObs — a root that does not match drops every
|
||||
// row, and a reader must be able to see that happen rather than read it as an
|
||||
// empty store.
|
||||
func listingObs(root armRoot, sessionPath string) ListingObs {
|
||||
if strings.TrimSpace(sessionPath) == "" {
|
||||
return ListingObs{}
|
||||
}
|
||||
store := delegation.NewSubagentStore(filepath.Join(root.Sessions, "subagents"))
|
||||
parent := strings.TrimSuffix(filepath.Base(sessionPath), ".jsonl")
|
||||
artifacts, err := store.ListForParent(parent, root.Workspace)
|
||||
if err != nil {
|
||||
return ListingObs{Err: err.Error()}
|
||||
}
|
||||
out := ListingObs{}
|
||||
for _, a := range artifacts {
|
||||
out.Rows = append(out.Rows, ListingRow{
|
||||
Ref: a.Ref, Status: string(a.Meta.Status), From: a.Meta.ParentToolCallID,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type ArtifactObs struct {
|
||||
Name string `json:"name"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
func capture(phase, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, root armRoot) Observation {
|
||||
path := ctrl.SessionPath()
|
||||
history := ctrl.History()
|
||||
system := systemText(history)
|
||||
snap := ctrl.ContextMaintenanceSnapshot()
|
||||
st, loaded, loadErr := sessionstore.LoadCompactionState(path)
|
||||
view, viewMsgs := viewObs(st, loaded, history)
|
||||
graph, deltas := sink.snapshot()
|
||||
return Observation{
|
||||
Phase: phase,
|
||||
Arm: arm,
|
||||
SessionPath: path,
|
||||
SystemHash: shortSum(system),
|
||||
SystemBytes: len(system),
|
||||
BootSystemHash: shortSum(bootSystem),
|
||||
BootSystemBytes: len(bootSystem),
|
||||
Transcript: TranscriptObs{Messages: len(history), Digest: transcriptDigest(history)},
|
||||
Goal: GoalObs{Goal: ctrl.Goal(), Status: ctrl.GoalStatus(), TurnsUsed: ctrl.GoalRuntime().TurnsUsed},
|
||||
Todos: ctrl.Todos(),
|
||||
Decisions: ctrl.Decisions(),
|
||||
Context: ContextObs{
|
||||
ProjectionVersion: snap.ProjectionVersion,
|
||||
CheckpointState: snap.CheckpointState,
|
||||
CanonicalTokens: snap.CanonicalTokens,
|
||||
ProjectedTokens: snap.ProjectedTokens,
|
||||
Blocked: snap.Blocked,
|
||||
},
|
||||
Sidecar: sidecarObs(st, loaded, loadErr),
|
||||
View: view,
|
||||
// The note is derived for a request and stored nowhere, so the contracts
|
||||
// about it are read off the request rather than off the stored view.
|
||||
TodoNotes: todoNoteObs(ctrl.ModelVisibleMessages(), viewMsgs, ctrl.Todos()),
|
||||
Graph: graphObs(graph, deltas),
|
||||
WaitCauses: waitCauseCounts(graph),
|
||||
WaitSeries: sink.waitSeries(),
|
||||
Progress: sink.phaseSeries(),
|
||||
SettledWorkers: settledWorkers(graph),
|
||||
QueuedWorkers: queuedWorkers(graph),
|
||||
Children: childrenObs(root, path),
|
||||
Listing: listingObs(root, path),
|
||||
FanOutTurn: fanOutTurnRecorded(history),
|
||||
Executions: control.ExecutionHistory(path),
|
||||
InterruptedExecutions: ctrl.InterruptedExecutions(),
|
||||
ModelSeesInterruptedExecution: countBlocks(
|
||||
ctrl.ModelVisibleMessages(), "<interrupted-execution>"),
|
||||
Deferred: deferredObs(root.Workspace),
|
||||
Obligation: obligationObs(history),
|
||||
Interrupted: ctrl.InterruptedAdjudications(),
|
||||
Journal: control.AdjudicationHistory(path),
|
||||
ModelSeesInterruption: countBlocks(ctrl.ModelVisibleMessages(), "<interrupted-adjudication>"),
|
||||
Artifacts: readArtifacts(path),
|
||||
}
|
||||
}
|
||||
|
||||
// bootSystemText is what the freshly built assembly composed, read before a
|
||||
// resume replaces the session under it.
|
||||
func bootSystemText(ctrl *control.Controller) string { return systemText(ctrl.History()) }
|
||||
|
||||
func systemText(msgs []provider.Message) string {
|
||||
var b strings.Builder
|
||||
for _, m := range msgs {
|
||||
if m.Role == provider.RoleSystem {
|
||||
b.WriteString(m.Content)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// transcriptDigest covers only what reaches a provider, so a local-only marker
|
||||
// appended by either process cannot read as a changed conversation.
|
||||
func transcriptDigest(msgs []provider.Message) string {
|
||||
h := sha256.New()
|
||||
for _, m := range provider.ModelMessages(msgs) {
|
||||
b, _ := json.Marshal(struct {
|
||||
R, C, N string
|
||||
}{string(m.Role), m.Content, m.Name})
|
||||
h.Write(b)
|
||||
h.Write([]byte{'\n'})
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||
}
|
||||
|
||||
func shortSum(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
func sidecarObs(st sessionstore.CompactionState, ok bool, err error) SidecarObs {
|
||||
out := SidecarObs{Present: ok}
|
||||
if err != nil {
|
||||
out.Err = err.Error()
|
||||
return out
|
||||
}
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
out.Messages = len(st.Projection.Messages)
|
||||
out.CoveredCount = st.Projection.CoveredCount
|
||||
out.CoveredPrefixHash = st.Projection.CoveredPrefixHash
|
||||
out.ProjectionVersion = st.Projection.ProjectionVersion
|
||||
out.TranscriptVersion = st.TranscriptVersion
|
||||
out.Generation = st.Generation
|
||||
return out
|
||||
}
|
||||
|
||||
// viewObs splices the model-visible context. With no usable projection the
|
||||
// view is the canonical transcript, which is what the host would send.
|
||||
func viewObs(st sessionstore.CompactionState, ok bool, canonical []provider.Message) (ViewObs, []provider.Message) {
|
||||
var out ViewObs
|
||||
if !ok || len(st.Projection.Messages) == 0 {
|
||||
out.Messages = len(canonical)
|
||||
out.Markers = markersIn(contents(canonical), markerPattern)
|
||||
out.Echoes = markersIn(contents(canonical), echoPattern)
|
||||
return out, canonical
|
||||
}
|
||||
body := st.Projection.Messages
|
||||
out.BodyMarkers = markersIn(contents(body), markerPattern)
|
||||
out.BodyEchoes = markersIn(contents(body), echoPattern)
|
||||
view := append([]provider.Message(nil), body...)
|
||||
if n := st.Projection.CoveredCount; n >= 0 && n < len(canonical) {
|
||||
view = append(view, canonical[n:]...)
|
||||
out.SplicedFromTail = len(canonical) - n
|
||||
}
|
||||
out.Messages = len(view)
|
||||
out.Markers = markersIn(contents(view), markerPattern)
|
||||
out.Echoes = markersIn(contents(view), echoPattern)
|
||||
return out, view
|
||||
}
|
||||
|
||||
// mentionsID reads an id the way the host's own visibility check does: text or
|
||||
// tool arguments, anywhere in the messages the request carries.
|
||||
func mentionsID(msgs []provider.Message, id string) bool {
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(m.Content, id) {
|
||||
return true
|
||||
}
|
||||
for _, call := range m.ToolCalls {
|
||||
if strings.Contains(call.Arguments, id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func countBlocks(msgs []provider.Message, marker string) int {
|
||||
n := 0
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(m.Content, marker) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func contents(msgs []provider.Message) []string {
|
||||
out := make([]string, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
out = append(out, m.Content)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// waitCauseCounts is how many nodes the graph shows under each refusal. It
|
||||
// counts nodes rather than reports: a node keeps its cause after it starts, so
|
||||
// this answers what was refused, not what is refused now.
|
||||
func waitCauseCounts(g agentgraph.Graph) map[string]int {
|
||||
out := map[string]int{}
|
||||
for _, n := range g.Nodes {
|
||||
if n.Wait != "" {
|
||||
out[string(n.Wait)]++
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// queuedWorkers are the items still refused admission, in a stable order.
|
||||
func queuedWorkers(g agentgraph.Graph) []string {
|
||||
var out []string
|
||||
for _, n := range g.Nodes {
|
||||
if n.Kind != agentgraph.KindGroup && n.State == agentgraph.StateQueued {
|
||||
out = append(out, n.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func graphObs(g agentgraph.Graph, deltas int) GraphObs {
|
||||
out := GraphObs{Deltas: deltas}
|
||||
if len(g.Nodes) == 0 {
|
||||
return out
|
||||
}
|
||||
out.Nodes = g.Nodes
|
||||
out.Edges = g.Edges
|
||||
out.Grants = map[string]string{}
|
||||
out.Waits = map[string]string{}
|
||||
for _, n := range g.Nodes {
|
||||
if n.Grant != "" {
|
||||
out.Grants[n.ID] = string(n.Grant)
|
||||
}
|
||||
if n.Wait != "" {
|
||||
out.Waits[n.ID] = string(n.Wait)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readArtifacts lists the session sidecars that exist, which is the evidence
|
||||
// behind every "persisted" claim in the matrix.
|
||||
func readArtifacts(sessionPath string) []ArtifactObs {
|
||||
if sessionPath == "" {
|
||||
return nil
|
||||
}
|
||||
paths := append([]string{sessionPath}, store.SessionSidecarFiles(sessionPath)...)
|
||||
var out []ArtifactObs
|
||||
for _, p := range paths {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, ArtifactObs{Name: filepath.Base(p), Bytes: info.Size()})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeObservation(root armRoot, obs Observation) error {
|
||||
b, err := json.MarshalIndent(obs, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(root.Dir, "obs-"+obs.Phase+".json"), b, 0o644)
|
||||
}
|
||||
|
||||
func readObservation(root armRoot, phase string) (Observation, error) {
|
||||
var obs Observation
|
||||
b, err := os.ReadFile(filepath.Join(root.Dir, "obs-"+phase+".json"))
|
||||
if err != nil {
|
||||
return obs, err
|
||||
}
|
||||
return obs, json.Unmarshal(b, &obs)
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/runtime/agent"
|
||||
"reasonix/internal/session/control"
|
||||
"reasonix/internal/state/checkpoint"
|
||||
)
|
||||
|
||||
// probeTurns is enough conversation for a fold to have a region to work on:
|
||||
// the system row and the first user turn are pinned, and the recent tail is
|
||||
// kept verbatim, so a short session has nothing foldable between them.
|
||||
const probeTurns = 8
|
||||
|
||||
// refoldTurns grows the transcript past the first fold by less than the fold
|
||||
// keeps verbatim, so the second fold has to reach into the stored body.
|
||||
const refoldTurns = 3
|
||||
|
||||
// runTurns drives count turns, each carrying its own marker, and returns the
|
||||
// next turn number.
|
||||
func runTurns(ctx context.Context, ctrl *control.Controller, from, count int) (int, error) {
|
||||
for i := range count {
|
||||
n := from + i + 1
|
||||
prompt := fmt.Sprintf("%s Probe turn %d: record the task list and keep working.", marker(n), n)
|
||||
if err := ctrl.Run(ctx, prompt); err != nil {
|
||||
return n, fmt.Errorf("turn %d: %w", n, err)
|
||||
}
|
||||
}
|
||||
return from + count, nil
|
||||
}
|
||||
|
||||
// openDecisionAndDie leaves a question open and kills the process while the
|
||||
// turn is still parked on it. It exits rather than returning: a clean shutdown
|
||||
// would resolve or cancel the question, which is the one thing a process that
|
||||
// dies mid-decision does not do.
|
||||
func openDecisionAndDie(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink) error {
|
||||
// The gate a person answers through, wired the way chat and desktop wire
|
||||
// it. Without it the ask tool decides for itself and nothing ever blocks.
|
||||
ctrl.EnableInteractiveApproval()
|
||||
go func() { _ = ctrl.Run(ctx, marker(99)+" "+askSentinel+": ask before writing.") }()
|
||||
if err := waitForAsk(ctrl); err != nil {
|
||||
return err
|
||||
}
|
||||
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
|
||||
if obs.Deferred.Executed {
|
||||
return errUnexpected("a write still held back by the open question", obs.Deferred.MarkerPath)
|
||||
}
|
||||
if err := writeObservation(root, obs); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForAsk blocks until the host reports a question waiting on a person.
|
||||
func waitForAsk(ctrl *control.Controller) error {
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
for _, d := range ctrl.Decisions() {
|
||||
if d.Kind == control.DecisionAsk {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return errUnexpected("a question waiting on a person", ctrl.Decisions())
|
||||
}
|
||||
|
||||
// rewindTurn drives the rewind a person drives, recording the projection
|
||||
// before it: the tail arm takes back the turn appended after the fold, the
|
||||
// covered arm lands below the fold boundary and must lose the projection
|
||||
// whatever coverage says.
|
||||
func rewindTurn(root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink) error {
|
||||
points := ctrl.Checkpoints()
|
||||
if len(points) == 0 {
|
||||
return errUnexpected("a checkpoint to rewind to", 0)
|
||||
}
|
||||
target := points[len(points)-1].Turn
|
||||
if arm == armCoveredRewind {
|
||||
var err error
|
||||
if target, err = turnBelowFold(ctrl, points); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeObservation(root, capture(extraPhase(arm), arm, bootSystem, ctrl, sink, root)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctrl.Rewind(target, control.RewindConversation); err != nil {
|
||||
return fmt.Errorf("rewind: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// turnBelowFold picks the checkpoint whose boundary sits just below the fold,
|
||||
// read from the sidecar rather than assumed: a control aiming at a fixed turn
|
||||
// stops removing folded history the moment coverage moves, and says nothing.
|
||||
// A boundary at the first message is skipped — a session left holding only a
|
||||
// system row is never persisted, so the restart would read the arm's work back.
|
||||
func turnBelowFold(ctrl *control.Controller, points []checkpoint.Meta) (int, error) {
|
||||
covered := 0
|
||||
if st, ok, _ := sessionstore.LoadCompactionState(ctrl.SessionPath()); ok {
|
||||
covered = st.Projection.CoveredCount
|
||||
}
|
||||
target := -1
|
||||
for _, p := range points {
|
||||
plan, err := ctrl.PrepareRewind(p.Turn, control.RewindConversation)
|
||||
if err != nil || !plan.HasBoundary {
|
||||
continue
|
||||
}
|
||||
if plan.BoundaryIndex > 1 && plan.BoundaryIndex < covered {
|
||||
target = p.Turn
|
||||
}
|
||||
}
|
||||
if target < 0 {
|
||||
return 0, errUnexpected("a checkpoint below the fold boundary", covered)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// extraPhase names the in-process observation an arm records before its
|
||||
// process exits, empty when it records none.
|
||||
func extraPhase(arm string) string {
|
||||
switch arm {
|
||||
case armRefoldIntoBody:
|
||||
return "prefold"
|
||||
case armTailRewind, armCoveredRewind:
|
||||
return "prerewind"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const probeGoal = "Prove which runtime semantics survive an OS process boundary."
|
||||
|
||||
// armConstruct hands the arm to the construct path it needs, reporting whether
|
||||
// one took over. Each of them records its own observation and exits inside the
|
||||
// turn, so a caller that sees taken has nothing left to do but report the error.
|
||||
func armConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) (bool, error) {
|
||||
switch {
|
||||
case arm == armOpenDecision || successorArm(arm):
|
||||
return true, openDecisionAndDie(ctx, root, arm, bootSystem, ctrl, sink)
|
||||
case deriveArm(arm):
|
||||
return true, runDeriveConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
|
||||
case terminalArm(arm):
|
||||
return true, runTerminalConstruct(ctx, root, arm, bootSystem, ctrl, sink, turn)
|
||||
case schedulerWaitArm(arm):
|
||||
return true, runSchedulerWaitConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
|
||||
case lineageArm(arm):
|
||||
return true, runLineageConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
|
||||
case cancelRoutingArm(arm):
|
||||
return true, runCancelRoutingConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
|
||||
case loneTaskArm(arm):
|
||||
return true, runLoneTaskConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
|
||||
case activeStoreArm(arm):
|
||||
return true, runActiveStoreConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
|
||||
case skillArm(arm):
|
||||
return true, runSkillClassConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
|
||||
case ephemeralArm(arm):
|
||||
return true, runEphemeralConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
|
||||
case hostExecArm(arm):
|
||||
return true, runHostExecConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
|
||||
case graphArm(arm) || uiArm(arm):
|
||||
return true, runFanOutConstruct(ctx, root, arm, bootSystem, ctrl, sink, turn)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// runConstruct establishes host state through the same calls a frontend makes,
|
||||
// records what this process can see, and returns so the process can exit. It
|
||||
// never asserts: an arm that failed to establish something is reported as not
|
||||
// measured, which is the one reading a missing "before" supports.
|
||||
func runConstruct(dir, arm string) error {
|
||||
root := rootFor(dir)
|
||||
if err := root.create(); err != nil {
|
||||
return err
|
||||
}
|
||||
if skillArm(arm) || arm == armEphemeralSkill || hostExecArm(arm) {
|
||||
if err := writeProbeSkill(root.Workspace); err != nil {
|
||||
return fmt.Errorf("probe skill: %w", err)
|
||||
}
|
||||
}
|
||||
if total, writers := schedulerLimits(arm); total > 0 {
|
||||
if err := root.writeProjectConfig(total, writers); err != nil {
|
||||
return fmt.Errorf("project config: %w", err)
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
sink := &graphSink{}
|
||||
bus, into := uiBus(arm, sink)
|
||||
ctrl, prov, err := buildRuntime(ctx, root, arm, into)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build: %w", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
// The process that dies is a window in production, and a window records what
|
||||
// it served. Without that the trajectory this session leaves behind holds
|
||||
// none of its own frames.
|
||||
recordLikeAWindow(ctrl, bus)
|
||||
|
||||
bootSystem := bootSystemText(ctrl)
|
||||
ctrl.EnsureSessionPath()
|
||||
if ctrl.SessionPath() == "" {
|
||||
return errUnexpected("a session path", "")
|
||||
}
|
||||
turn, err := runTurns(ctx, ctrl, 0, probeTurns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctrl.SetGoalDurable(probeGoal); err != nil {
|
||||
return fmt.Errorf("goal: %w", err)
|
||||
}
|
||||
if _, err := ctrl.Compact(ctx, agent.CompactRequest{Instructions: "Fold the probe turn."}); err != nil {
|
||||
return fmt.Errorf("compact: %w", err)
|
||||
}
|
||||
if taken, err := armConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn); taken {
|
||||
return err
|
||||
}
|
||||
if arm == armTodoIdentity {
|
||||
// Identity A is already written and folded away. Grow, replace the list
|
||||
// with identity B, then fold again: the second fold is what decides
|
||||
// whether the model is left holding one current identity or two.
|
||||
if turn, err = runTurns(ctx, ctrl, turn, refoldTurns); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctrl.Run(ctx, fmt.Sprintf("%s %s: replace the task list.", marker(turn+1), retodoSentinel)); err != nil {
|
||||
return fmt.Errorf("retodo turn: %w", err)
|
||||
}
|
||||
// Grow well past the rewrite so the fold takes the new ids out of view.
|
||||
// A scenario that leaves them readable asks nothing of the note.
|
||||
if _, err = runTurns(ctx, ctrl, turn+1, probeTurns); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := ctrl.Compact(ctx, agent.CompactRequest{Instructions: "Fold again, after the identity moved."}); err != nil {
|
||||
return fmt.Errorf("refold: %w", err)
|
||||
}
|
||||
} else if arm == armRefoldIntoBody {
|
||||
// Grow past the fold, record the view a second fold will operate on,
|
||||
// then fold again: its region reaches back into the stored body, which
|
||||
// has no canonical counterpart to map a new boundary onto.
|
||||
if _, err = runTurns(ctx, ctrl, turn, refoldTurns); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeObservation(root, capture(extraPhase(arm), arm, bootSystem, ctrl, sink, root)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := ctrl.Compact(ctx, agent.CompactRequest{Instructions: "Fold again, into the stored body."}); err != nil {
|
||||
return fmt.Errorf("refold: %w", err)
|
||||
}
|
||||
} else if appendsAfterFold(arm) {
|
||||
if _, err = runTurns(ctx, ctrl, turn, 1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if arm == armTailRewind || arm == armCoveredRewind {
|
||||
if err := rewindTurn(root, arm, bootSystem, ctrl, sink); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writeObservation(root, capture("construct", arm, bootSystem, ctrl, sink, root))
|
||||
}
|
||||
|
||||
// successorArm reports whether this arm starts from an interrupted barrier.
|
||||
func successorArm(arm string) bool {
|
||||
_, ok := successorTurn(arm)
|
||||
return ok
|
||||
}
|
||||
|
||||
// runSuccessor is the middle process: it inherits the interruption and does
|
||||
// what a person would do next — nothing, unrelated work, or something that
|
||||
// reads like an answer to the dead question. It runs a model on purpose; the
|
||||
// phase that judges what the host knows still does not.
|
||||
func runSuccessor(dir, arm string) error {
|
||||
root := rootFor(dir)
|
||||
before, err := readObservation(root, "construct")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := context.Background()
|
||||
sink := &graphSink{}
|
||||
ctrl, _, err := buildRuntime(ctx, root, arm, sink)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build: %w", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
bootSystem := bootSystemText(ctrl)
|
||||
session, err := sessionstore.LoadSession(before.SessionPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load session: %w", err)
|
||||
}
|
||||
if err := ctrl.Resume(session, before.SessionPath); err != nil {
|
||||
return fmt.Errorf("resume: %w", err)
|
||||
}
|
||||
if turn, _ := successorTurn(arm); turn != "" {
|
||||
if err := ctrl.Run(ctx, turn); err != nil {
|
||||
return fmt.Errorf("successor turn: %w", err)
|
||||
}
|
||||
}
|
||||
return writeObservation(root, capture("successor", arm, bootSystem, ctrl, sink, root))
|
||||
}
|
||||
|
||||
// runResume boots a second time against the same roots and reads host state.
|
||||
// It deliberately never calls Run: the question is what the host can prove
|
||||
// before a provider call, and a model that reconstructs a lost identity from
|
||||
// the transcript would turn a real loss into a passing row.
|
||||
func runResume(dir, arm string) error {
|
||||
root := rootFor(dir)
|
||||
before, err := readObservation(root, "construct")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read construct observation: %w", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
sink := &graphSink{}
|
||||
bus, into := uiBus(arm, sink)
|
||||
ctrl, _, err := buildRuntime(ctx, root, arm, into)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build: %w", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
|
||||
bootSystem := bootSystemText(ctrl)
|
||||
session, err := sessionstore.LoadSession(before.SessionPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load session: %w", err)
|
||||
}
|
||||
if err := ctrl.Resume(session, before.SessionPath); err != nil {
|
||||
return fmt.Errorf("resume: %w", err)
|
||||
}
|
||||
obs := capture("resume", arm, bootSystem, ctrl, sink, root)
|
||||
if uiArm(arm) {
|
||||
ui := observeUI(root, ctrl, bus, obs)
|
||||
obs.UI = &ui
|
||||
}
|
||||
return writeObservation(root, obs)
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A fold may only remove a run of markers from the middle: the pinned head
|
||||
// stays, the retained tail stays, and everything between them becomes the
|
||||
// digest. Any other shape means the view lost something no fold folded.
|
||||
const (
|
||||
verdictContiguous = "contiguous"
|
||||
verdictHole = "HOLE"
|
||||
markPrefix = "PROBE-MARK-"
|
||||
echoPrefix = "PROBE-ECHO-"
|
||||
)
|
||||
|
||||
// refoldRows report what the second fold did. They are their own set because
|
||||
// the comparison is inside one process — the view before the refold against
|
||||
// the view after it — not across the process boundary.
|
||||
func refoldRows(prefold, postfold, resumed Observation, total int) []row {
|
||||
return []row{
|
||||
coverageRow("fold 1: stored body and coverage", prefold),
|
||||
coverageRow("fold 2: stored body and coverage", postfold),
|
||||
continuityRow("assistant work visible after fold 1", prefold, total),
|
||||
continuityRow("assistant work visible after fold 2", postfold, total),
|
||||
continuityRow("assistant work visible after restart", resumed, total),
|
||||
turnsRow("user turns visible after fold 2", postfold),
|
||||
}
|
||||
}
|
||||
|
||||
func coverageRow(semantic string, o Observation) row {
|
||||
return row{
|
||||
Semantic: semantic, Authority: "compaction sidecar", Artifact: "<stem>.context.json",
|
||||
Reconstruction: "projection.Messages + canonical[CoveredCount:]",
|
||||
Before: fmt.Sprintf("canonical=%d", o.Transcript.Messages),
|
||||
After: fmt.Sprintf("body=%d covered=%d spliced=+%d view=%d",
|
||||
o.Sidecar.Messages, o.Sidecar.CoveredCount, o.View.SplicedFromTail, o.View.Messages),
|
||||
Verdict: verdictStable,
|
||||
}
|
||||
}
|
||||
|
||||
// continuityRow judges the assistant side: a fold replaces one run of replies
|
||||
// with a digest, so anything but a single surviving run is a loss no fold made.
|
||||
func continuityRow(semantic string, o Observation, total int) row {
|
||||
shape := markerShape(o.View.Echoes, echoPrefix)
|
||||
verdict := verdictContiguous
|
||||
if reason := markerHole(o.View.Echoes, echoPrefix, total); reason != "" {
|
||||
verdict, shape = verdictHole, shape+" — "+reason
|
||||
}
|
||||
return row{
|
||||
Semantic: semantic, Authority: "the spliced model-visible view",
|
||||
Artifact: "none: derived", Reconstruction: "assistant replies surviving in the view",
|
||||
Before: fmt.Sprintf("emitted 1-%d", total), After: shape, Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
// turnsRow reports the user side beside it: the retention budget keeps those
|
||||
// verbatim, which is why a one-sided tag could not see a dropped reply.
|
||||
func turnsRow(semantic string, o Observation) row {
|
||||
return row{
|
||||
Semantic: semantic, Authority: "the spliced model-visible view",
|
||||
Artifact: "none: derived", Reconstruction: "user turns surviving in the view",
|
||||
Before: "kept by the retention budget",
|
||||
After: markerShape(o.View.Markers, markPrefix), Verdict: verdictStable,
|
||||
}
|
||||
}
|
||||
|
||||
// pad renders a tag number the way the scripted turns emit it.
|
||||
func pad(n int) string { return fmt.Sprintf("%03d", n) }
|
||||
|
||||
func markerNumbers(markers []string, prefix string) []int {
|
||||
seen := map[int]bool{}
|
||||
var out []int
|
||||
for _, m := range markers {
|
||||
n, err := strconv.Atoi(strings.TrimPrefix(m, prefix))
|
||||
if err != nil || seen[n] {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// markerShape renders the surviving markers as runs, which is the form the
|
||||
// judgement is about: "1, 9-12" says a fold took 2-8.
|
||||
func markerShape(markers []string, prefix string) string {
|
||||
nums := markerNumbers(markers, prefix)
|
||||
if len(nums) == 0 {
|
||||
return "none"
|
||||
}
|
||||
var runs []string
|
||||
start := nums[0]
|
||||
for i := 1; i <= len(nums); i++ {
|
||||
if i < len(nums) && nums[i] == nums[i-1]+1 {
|
||||
continue
|
||||
}
|
||||
if end := nums[i-1]; end == start {
|
||||
runs = append(runs, strconv.Itoa(start))
|
||||
} else {
|
||||
runs = append(runs, fmt.Sprintf("%d-%d", start, end))
|
||||
}
|
||||
if i < len(nums) {
|
||||
start = nums[i]
|
||||
}
|
||||
}
|
||||
return strings.Join(runs, ", ")
|
||||
}
|
||||
|
||||
// markerHole names the violation, or returns "" when the shape is one a fold
|
||||
// can produce: an optional pinned head starting at 1, then one run reaching the
|
||||
// last turn. Two gaps, or a tail that stops short of it, are losses.
|
||||
func markerHole(markers []string, prefix string, total int) string {
|
||||
nums := markerNumbers(markers, prefix)
|
||||
if len(nums) == 0 {
|
||||
return "the view shows no turn at all"
|
||||
}
|
||||
runs := strings.Split(markerShape(markers, prefix), ", ")
|
||||
if last := nums[len(nums)-1]; last != total {
|
||||
return fmt.Sprintf("the newest turn %d is missing", total)
|
||||
}
|
||||
if len(runs) > 2 {
|
||||
return fmt.Sprintf("%d separate runs; a fold removes one", len(runs))
|
||||
}
|
||||
if len(runs) == 2 && nums[0] != 1 {
|
||||
return fmt.Sprintf("the kept head starts at %d, not the pinned first turn", nums[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// The oracle has to be able to fail, or "no hole" says nothing. These are the
|
||||
// shapes a fold can produce and the shapes only a loss can.
|
||||
func TestMarkerHoleDiscriminates(t *testing.T) {
|
||||
echo := func(ns ...int) []string {
|
||||
out := make([]string, 0, len(ns))
|
||||
for _, n := range ns {
|
||||
out = append(out, echoPrefix+pad(n))
|
||||
}
|
||||
return out
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
total int
|
||||
holed bool
|
||||
}{
|
||||
{"nothing folded", echo(1, 2, 3, 4, 5), 5, false},
|
||||
{"middle folded away", echo(4, 5), 5, false},
|
||||
{"pinned head kept beside the tail", echo(1, 4, 5), 5, false},
|
||||
{"a reply dropped inside the kept tail", echo(3, 5), 5, true},
|
||||
{"two gaps", echo(1, 3, 5), 5, true},
|
||||
{"newest turn missing", echo(3, 4), 5, true},
|
||||
{"head kept but not the first turn", echo(2, 4, 5), 5, true},
|
||||
{"empty view", nil, 5, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
reason := markerHole(tc.in, echoPrefix, tc.total)
|
||||
if holed := reason != ""; holed != tc.holed {
|
||||
t.Fatalf("markerHole(%v) = %q, want holed=%v", tc.in, reason, tc.holed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkerShapeRendersRuns(t *testing.T) {
|
||||
in := []string{echoPrefix + pad(1), echoPrefix + pad(9), echoPrefix + pad(10), echoPrefix + pad(11)}
|
||||
if got := markerShape(in, echoPrefix); got != "1, 9-11" {
|
||||
t.Fatalf("markerShape = %q, want %q", got, "1, 9-11")
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// renderMatrix prints one table per arm. It reports what each row's value was
|
||||
// on both sides of the process boundary, because a verdict without the two
|
||||
// readings behind it is an assertion.
|
||||
func renderMatrix(results []armResult, work string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Runtime resurrection matrix\n\n")
|
||||
b.WriteString("Measurement boundary: OS process. Phase B loads and inspects; it never calls a model.\n\n")
|
||||
fmt.Fprintf(&b, "Arm roots: %s\n\n", work)
|
||||
for _, res := range results {
|
||||
fmt.Fprintf(&b, "## arm: %s\n\n%s\n\n", res.Arm, res.Asks)
|
||||
if res.Invalid != "" {
|
||||
fmt.Fprintf(&b, "**ARM INVALID — %s.** Its rows below establish nothing.\n\n", res.Invalid)
|
||||
}
|
||||
b.WriteString("| Semantic | Before death | After new process | Verdict | Authority | Artifact |\n")
|
||||
b.WriteString("| --- | --- | --- | --- | --- | --- |\n")
|
||||
for _, r := range res.Rows {
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s | %s |\n",
|
||||
r.Semantic, cell(r.Before), cell(r.After), r.Verdict, r.Authority, r.Artifact)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(summarize(res))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func summarize(res armResult) string {
|
||||
counts := map[string]int{}
|
||||
for _, r := range res.Rows {
|
||||
counts[r.Verdict]++
|
||||
}
|
||||
var parts []string
|
||||
// Every verdict the matrix can produce. A summary that silently omits one
|
||||
// is how a VIOLATED row reads as a clean arm.
|
||||
for _, v := range []string{verdictPersisted, verdictExact, verdictLossy, verdictLost, verdictNotMeasured,
|
||||
verdictChanged, verdictStable, verdictContiguous, verdictHole, verdictHolds, verdictViolated} {
|
||||
if counts[v] > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%s=%d", v, counts[v]))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ") + "\n"
|
||||
}
|
||||
|
||||
func cell(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "—"
|
||||
}
|
||||
s = strings.ReplaceAll(s, "|", "\\|")
|
||||
if len(s) > 64 {
|
||||
return s[:61] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Verdicts. They are deliberately not booleans: "the host holds it verbatim"
|
||||
// and "a fold over canonical artifacts happens to agree" are different claims
|
||||
// about durability, and a row that was never established supports neither.
|
||||
const (
|
||||
verdictPersisted = "persisted-direct"
|
||||
verdictExact = "reconstructed-exact"
|
||||
verdictLossy = "reconstructed-lossy"
|
||||
verdictLost = "lost"
|
||||
verdictNotMeasured = "not-measured"
|
||||
verdictChanged = "changed"
|
||||
verdictStable = "stable"
|
||||
)
|
||||
|
||||
type row struct {
|
||||
Semantic string `json:"semantic"`
|
||||
Authority string `json:"authority"`
|
||||
Artifact string `json:"artifact"`
|
||||
Reconstruction string `json:"reconstruction"`
|
||||
Before string `json:"before"`
|
||||
After string `json:"after"`
|
||||
Verdict string `json:"verdict"`
|
||||
}
|
||||
|
||||
type armResult struct {
|
||||
Arm string `json:"arm"`
|
||||
Asks string `json:"asks"`
|
||||
Invalid string `json:"invalid,omitempty"`
|
||||
Rows []row `json:"rows"`
|
||||
Extra *Observation `json:"extra,omitempty"`
|
||||
Before Observation `json:"before"`
|
||||
After Observation `json:"after"`
|
||||
}
|
||||
|
||||
func classify(a arm, extra *Observation, before, after Observation) armResult {
|
||||
res := armResult{Arm: a.name, Asks: a.asks, Extra: extra, Before: before, After: after}
|
||||
prefix := prefixRow(before, after)
|
||||
res.Rows = append(res.Rows, prefix)
|
||||
if a.name == "system-swap" && prefix.Verdict != verdictChanged {
|
||||
res.Invalid = "the lever did not move the stable prefix, so this arm measures nothing"
|
||||
}
|
||||
if a.name == "exact" && prefix.Verdict != verdictStable {
|
||||
res.Invalid = "the stable prefix moved without a lever, so this arm is not the control it claims"
|
||||
}
|
||||
res.Rows = append(res.Rows,
|
||||
valueRow("system row in the session", "the transcript's own leading system message",
|
||||
"<stem>.jsonl", "loaded with the transcript",
|
||||
before.SystemHash, after.SystemHash, true),
|
||||
valueRow("canonical transcript", "session transcript + event log",
|
||||
"<stem>.jsonl, <stem>.events.jsonl", "LoadSession replays the append log",
|
||||
before.Transcript.Digest, after.Transcript.Digest, true),
|
||||
valueRow("active goal identity", "goalMachine (control)",
|
||||
"<stem>.goal-state.json", "restoreFromState on Resume",
|
||||
before.Goal.Goal, after.Goal.Goal, true),
|
||||
valueRow("goal status", "goalMachine (control)",
|
||||
"<stem>.goal-state.json", "restoreFromState on Resume",
|
||||
before.Goal.Status, after.Goal.Status, true),
|
||||
valueRow("todo host identity", "agent todoState (host-owned, never in the prompt)",
|
||||
"none verbatim; goal-state.json snapshots a terminal goal's list",
|
||||
"rebuilt from the transcript's todo_write / complete_step receipts",
|
||||
todoIDs(before), todoIDs(after), false),
|
||||
valueRow("open decisions", "approvalManager (control, in-memory)",
|
||||
"none", "none: derived from live pending approvals/asks",
|
||||
decisionIDs(before), decisionIDs(after), false),
|
||||
valueRow("compaction projection (stored)", "compaction sidecar",
|
||||
"<stem>.context.json", "LoadProjectionSidecar on bind",
|
||||
sidecarClaim(before), sidecarClaim(after), true),
|
||||
useRow("compaction projection (in use)", before, after),
|
||||
valueRow("run graph nodes", "event sink (GraphDelta)",
|
||||
"none observed", "fold GraphDelta events, if any survive",
|
||||
graphNodes(before), graphNodes(after), false),
|
||||
valueRow("run graph grants", "event sink (GraphDelta)",
|
||||
"none observed", "fold GraphDelta events, if any survive",
|
||||
graphMap(before.Graph.Grants), graphMap(after.Graph.Grants), false),
|
||||
valueRow("run graph wait causes", "event sink (GraphDelta)",
|
||||
"none observed", "fold GraphDelta events, if any survive",
|
||||
graphMap(before.Graph.Waits), graphMap(after.Graph.Waits), false),
|
||||
)
|
||||
switch {
|
||||
case successorArm(a.name) && extra != nil:
|
||||
res.Rows = append(res.Rows, successorRows(a.name, *extra, after)...)
|
||||
// The premise is a barrier open at death. The construct process is its
|
||||
// own waiter, so it reports no interruption of its own.
|
||||
if len(before.Decisions) == 0 {
|
||||
res.Invalid = "no decision was open when the first process died"
|
||||
}
|
||||
case a.name == armOpenDecision:
|
||||
res.Rows = append(res.Rows, decisionRows(before, after)...)
|
||||
if len(before.Decisions) == 0 {
|
||||
res.Invalid = "no decision was open when the process died, so nothing was interrupted"
|
||||
}
|
||||
case deriveArm(a.name):
|
||||
res.Rows = append(res.Rows, deriveRows(a.name, before, after)...)
|
||||
res.Invalid = deriveArmInvalid(a.name, before)
|
||||
case terminalArm(a.name):
|
||||
res.Rows = append(res.Rows, terminalRows(a.name, before, after)...)
|
||||
res.Invalid = terminalArmInvalid(a.name, before)
|
||||
case schedulerWaitArm(a.name):
|
||||
res.Rows = append(res.Rows, schedulerWaitRows(a.name, before, after)...)
|
||||
res.Invalid = schedulerArmInvalid(a.name, before)
|
||||
case graphArm(a.name):
|
||||
res.Rows = append(res.Rows, graphRows(before, after)...)
|
||||
res.Invalid = graphArmInvalid(a.name, before)
|
||||
case uiArm(a.name):
|
||||
res.Rows = append(res.Rows, uiRows(before, after)...)
|
||||
res.Invalid = uiArmInvalid(before, after)
|
||||
case lineageArm(a.name):
|
||||
res.Rows = append(res.Rows, lineageRows(a.name, before)...)
|
||||
case cancelRoutingArm(a.name):
|
||||
res.Rows = append(res.Rows, cancelRoutingRows(a.name, before)...)
|
||||
case loneTaskArm(a.name):
|
||||
res.Rows = append(res.Rows, loneTaskRows(a.name, before, after)...)
|
||||
res.Invalid = loneTaskArmInvalid(a.name, before)
|
||||
case activeStoreArm(a.name):
|
||||
res.Rows = append(res.Rows, activeStoreRows(before, after)...)
|
||||
res.Invalid = activeStoreInvalid(before)
|
||||
case skillArm(a.name):
|
||||
res.Rows = append(res.Rows, skillClassRows(a.name, before, after)...)
|
||||
res.Invalid = skillArmInvalid(a.name, before)
|
||||
case ephemeralArm(a.name):
|
||||
res.Rows = append(res.Rows, ephemeralRows(a.name, before, after)...)
|
||||
res.Invalid = ephemeralArmInvalid(before)
|
||||
case hostExecArm(a.name):
|
||||
res.Rows = append(res.Rows, hostExecRows(a.name, before, after)...)
|
||||
res.Invalid = hostExecArmInvalid(a.name, before)
|
||||
case a.name == armTodoIdentity:
|
||||
res.Rows = append(res.Rows, todoRows(before, after)...)
|
||||
case a.name == armRefoldIntoBody && extra != nil:
|
||||
res.Rows = append(res.Rows, refoldRows(*extra, before, after, probeTurns+refoldTurns)...)
|
||||
if extra.Sidecar.Messages == 0 {
|
||||
res.Invalid = "the first fold stored no body, so the second had none to reach into"
|
||||
}
|
||||
case (a.name == armTailRewind || a.name == armCoveredRewind) && extra != nil:
|
||||
res.Rows = append(res.Rows, useRow("projection across the rewind, same process", *extra, before))
|
||||
res.Rows = append(res.Rows, rewindLandingRow(a.name, *extra, before))
|
||||
res.Invalid = rewindLandedWrong(a.name, *extra, before)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// prefixRow is the arm's own control rather than a durability claim: it reports
|
||||
// whether the stable prefix moved, which is the thing each arm sets up.
|
||||
func prefixRow(before, after Observation) row {
|
||||
verdict := verdictStable
|
||||
if before.BootSystemHash != after.BootSystemHash {
|
||||
verdict = verdictChanged
|
||||
}
|
||||
return row{
|
||||
Semantic: "stable prefix identity (composed)", Authority: "boot assembly (recomposed per process)",
|
||||
Artifact: "none (composed from config, memory and instructions)", Reconstruction: "recomposed at boot",
|
||||
Before: before.BootSystemHash, After: after.BootSystemHash, Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
func valueRow(semantic, authority, artifact, reconstruction, before, after string, persisted bool) row {
|
||||
r := row{Semantic: semantic, Authority: authority, Artifact: artifact,
|
||||
Reconstruction: reconstruction, Before: before, After: after}
|
||||
switch {
|
||||
case before == "" || before == "-":
|
||||
r.Verdict = verdictNotMeasured
|
||||
case after == "" || after == "-":
|
||||
r.Verdict = verdictLost
|
||||
case before != after:
|
||||
r.Verdict = verdictLossy
|
||||
case persisted:
|
||||
r.Verdict = verdictPersisted
|
||||
default:
|
||||
r.Verdict = verdictExact
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func todoIDs(o Observation) string {
|
||||
var ids []string
|
||||
for _, t := range o.Todos {
|
||||
id := t.StepID
|
||||
if id == "" {
|
||||
id = "(no step_id)"
|
||||
}
|
||||
ids = append(ids, id+":"+t.Status)
|
||||
}
|
||||
return join(ids)
|
||||
}
|
||||
|
||||
func decisionIDs(o Observation) string {
|
||||
var ids []string
|
||||
for _, d := range o.Decisions {
|
||||
ids = append(ids, string(d.Kind)+":"+d.ID)
|
||||
}
|
||||
return join(ids)
|
||||
}
|
||||
|
||||
func sidecarClaim(o Observation) string {
|
||||
if !o.Sidecar.Present {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("v%d msgs=%d covered=%d hash=%s",
|
||||
o.Sidecar.ProjectionVersion, o.Sidecar.Messages, o.Sidecar.CoveredCount, short(o.Sidecar.CoveredPrefixHash))
|
||||
}
|
||||
|
||||
// rewindLandingRow says where the rewind actually landed relative to the fold.
|
||||
// An arm that names a side and does not reach it is not measuring that side.
|
||||
func rewindLandingRow(arm string, prerewind, after Observation) row {
|
||||
side := "above the fold boundary"
|
||||
if after.Transcript.Messages < prerewind.Sidecar.CoveredCount {
|
||||
side = "below the fold boundary"
|
||||
}
|
||||
return row{
|
||||
Semantic: "where the rewind landed", Authority: "checkpoint boundary against the sidecar",
|
||||
Artifact: "none: derived", Reconstruction: "canonical length after the rewind against CoveredCount before it",
|
||||
Before: fmt.Sprintf("covered=%d", prerewind.Sidecar.CoveredCount),
|
||||
After: fmt.Sprintf("canonical=%d, %s", after.Transcript.Messages, side), Verdict: verdictStable,
|
||||
}
|
||||
}
|
||||
|
||||
// rewindLandedWrong invalidates an arm that aimed at one side of the fold and
|
||||
// reached the other. covered-rewind stopped removing folded history the moment
|
||||
// coverage became a real boundary, and nothing in the matrix said so.
|
||||
func rewindLandedWrong(arm string, prerewind, after Observation) string {
|
||||
below := after.Transcript.Messages < prerewind.Sidecar.CoveredCount
|
||||
switch {
|
||||
case arm == armCoveredRewind && !below:
|
||||
return "the rewind landed above the fold boundary, so it removed no folded history"
|
||||
case arm == armTailRewind && below:
|
||||
return "the rewind landed below the fold boundary, so it is not a tail-only truncation"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// useRow turns on whether the next request would ride the projection, not on
|
||||
// the token counts beside it: a tail edit legitimately moves those, and letting
|
||||
// them decide the verdict reported a surviving projection as a loss.
|
||||
func useRow(semantic string, before, after Observation) row {
|
||||
r := valueRow(semantic, "projectionValid over the covered prefix",
|
||||
"none: a judgement, not a stored value",
|
||||
"revalidated against the canonical prefix on every read",
|
||||
projectionState(before), projectionState(after), false)
|
||||
r.Before, r.After = projectionUse(before), projectionUse(after)
|
||||
return r
|
||||
}
|
||||
|
||||
// projectionState is the compared value: in use, not in use, or never measured.
|
||||
func projectionState(o Observation) string {
|
||||
switch o.Context.CheckpointState {
|
||||
case "":
|
||||
return ""
|
||||
case "none":
|
||||
return "NOT-in-use"
|
||||
}
|
||||
return "in-use"
|
||||
}
|
||||
|
||||
// projectionUse reads the host's own judgement rather than the stored claim.
|
||||
// CheckpointState is "none" whenever validation failed, which is the difference
|
||||
// between a sidecar that loaded and one the next request will actually ride.
|
||||
// The label itself is not compared — "applied" and "restored" are the same
|
||||
// answer reached from different sides — so the row turns on use and size.
|
||||
func projectionUse(o Observation) string {
|
||||
if o.Context.CheckpointState == "" {
|
||||
return ""
|
||||
}
|
||||
use := "in-use"
|
||||
if o.Context.CheckpointState == "none" {
|
||||
use = "NOT-in-use"
|
||||
}
|
||||
return fmt.Sprintf("%s projected=%d canonical=%d", use,
|
||||
o.Context.ProjectedTokens, o.Context.CanonicalTokens)
|
||||
}
|
||||
|
||||
// graphNodes renders the fold's nodes in a stable order. The fold keeps
|
||||
// first-seen order, which two processes reach by different routes, so an
|
||||
// unsorted rendering would report a reordering as a changed graph.
|
||||
func graphNodes(o Observation) string {
|
||||
if len(o.Graph.Nodes) == 0 {
|
||||
return ""
|
||||
}
|
||||
ids := make([]string, 0, len(o.Graph.Nodes))
|
||||
for _, n := range o.Graph.Nodes {
|
||||
ids = append(ids, n.ID+":"+string(n.State))
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return join(ids)
|
||||
}
|
||||
|
||||
// graphMap renders one per-node attribute set. Map iteration is unordered, so
|
||||
// without the sort two identical envelopes would compare as different.
|
||||
func graphMap(m map[string]string) string {
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := make([]string, 0, len(m))
|
||||
for k, v := range m {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func join(in []string) string {
|
||||
if len(in) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(in, ", ")
|
||||
}
|
||||
|
||||
func short(s string) string {
|
||||
if len(s) <= 12 {
|
||||
return s
|
||||
}
|
||||
return s[:12]
|
||||
}
|
||||
|
||||
func marshalResults(results []armResult) ([]byte, error) {
|
||||
return json.MarshalIndent(results, "", " ")
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
"reasonix/internal/contract/agentgraph"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
// armRoot names the four directories one arm owns. Home and Workspace are
|
||||
// separate so a lever can change what the prefix folds without touching the
|
||||
// transcript, and Sessions is explicit so the probe never writes to the
|
||||
// developer's own session directory.
|
||||
type armRoot struct{ Home, Workspace, Sessions, Dir string }
|
||||
|
||||
func rootFor(dir string) armRoot {
|
||||
return armRoot{
|
||||
Dir: dir,
|
||||
Home: filepath.Join(dir, "home"),
|
||||
Workspace: filepath.Join(dir, "workspace"),
|
||||
Sessions: filepath.Join(dir, "sessions"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r armRoot) create() error {
|
||||
for _, d := range []string{r.Home, r.Workspace, r.Sessions} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeProjectConfig puts a project config beside the workspace, which is where
|
||||
// a person configures these ceilings. The scheduler arms need them small enough
|
||||
// to reach a specific refusal, and a poked field would prove nothing about the
|
||||
// judgement production makes.
|
||||
func (r armRoot) writeProjectConfig(total, writers int) error {
|
||||
body := fmt.Sprintf("[agent]\nmax_subagent_concurrency = %d\nmax_parallel_writers = %d\n", total, writers)
|
||||
return os.WriteFile(filepath.Join(r.Workspace, "reasonix.toml"), []byte(body), 0o644)
|
||||
}
|
||||
|
||||
// requireFresh refuses a root a previous run left behind. A reused root is how
|
||||
// a lever's effect ends up already present in the construct phase, which reads
|
||||
// as "the lever changed nothing" — a false negative that looks like a result.
|
||||
func (r armRoot) requireFresh() error {
|
||||
entries, err := os.ReadDir(r.Dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
return fmt.Errorf("arm root %s is not empty; each run needs a clean root", r.Dir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// graphSink records what the run graph published while this process was alive.
|
||||
// It is the only "before" the graph row has: nothing else in a session's
|
||||
// artifacts is known to carry it, and a row with no before is not measured.
|
||||
type graphSink struct {
|
||||
mu sync.Mutex
|
||||
graph agentgraph.Graph
|
||||
deltas int
|
||||
// waits is every wait cause published per node, in order. The fold keeps
|
||||
// only the latest, which cannot say whether a cause was reported once or
|
||||
// replaced — and that is exactly what the transition arm asks.
|
||||
waits map[string][]string
|
||||
// phases is every subagent status the parent was told, per call. A run
|
||||
// handed to a job publishes no graph node at all, so for those this is the
|
||||
// only live evidence that anything happened.
|
||||
phases map[string][]string
|
||||
// tools is when each call was dispatched and when it answered. A stop that
|
||||
// reached a running tool is visible as the second arriving early.
|
||||
tools map[string][2]time.Time
|
||||
}
|
||||
|
||||
func (s *graphSink) Emit(e event.Event) {
|
||||
if e.Kind == event.ToolDispatch || e.Kind == event.ToolResult {
|
||||
s.noteTool(e)
|
||||
return
|
||||
}
|
||||
if id, phase, ok := progressPhase(e); ok {
|
||||
s.mu.Lock()
|
||||
if s.phases == nil {
|
||||
s.phases = map[string][]string{}
|
||||
}
|
||||
s.phases[id] = append(s.phases[id], phase)
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if e.Kind != event.GraphDelta || e.Graph == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.deltas++
|
||||
for _, n := range e.Graph.Nodes {
|
||||
if n.Wait == "" {
|
||||
continue
|
||||
}
|
||||
if s.waits == nil {
|
||||
s.waits = map[string][]string{}
|
||||
}
|
||||
s.waits[n.ID] = append(s.waits[n.ID], string(n.Wait))
|
||||
}
|
||||
s.graph.Apply(*e.Graph)
|
||||
}
|
||||
|
||||
func (s *graphSink) snapshot() (agentgraph.Graph, int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.graph, s.deltas
|
||||
}
|
||||
|
||||
// noteTool records the two edges of a tool call this probe may be asked about.
|
||||
func (s *graphSink) noteTool(e event.Event) {
|
||||
if e.Tool.ID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.tools == nil {
|
||||
s.tools = map[string][2]time.Time{}
|
||||
}
|
||||
at := s.tools[e.Tool.ID]
|
||||
if e.Kind == event.ToolDispatch {
|
||||
at[0] = time.Now()
|
||||
} else {
|
||||
at[1] = time.Now()
|
||||
}
|
||||
s.tools[e.Tool.ID] = at
|
||||
}
|
||||
|
||||
func (s *graphSink) toolDispatched(id string) (time.Time, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
at, ok := s.tools[id]
|
||||
return at[0], ok && !at[0].IsZero()
|
||||
}
|
||||
|
||||
func (s *graphSink) toolResult(id string) (time.Time, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
at, ok := s.tools[id]
|
||||
return at[1], ok && !at[1].IsZero()
|
||||
}
|
||||
|
||||
// phaseSeries is every status published for one call, in publication order.
|
||||
func (s *graphSink) phaseSeries() map[string][]string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := map[string][]string{}
|
||||
for id, seq := range s.phases {
|
||||
out[id] = append([]string(nil), seq...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// waitSeries is every cause published for one node, in publication order.
|
||||
func (s *graphSink) waitSeries() map[string][]string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := map[string][]string{}
|
||||
for id, seq := range s.waits {
|
||||
out[id] = append([]string(nil), seq...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildRuntime assembles a real runtime bound to this arm's roots. Everything a
|
||||
// frontend would supply is supplied; only the provider is scripted, so the
|
||||
// probe never depends on a network or a key.
|
||||
func buildRuntime(ctx context.Context, root armRoot, arm string, sink event.Sink) (*control.Controller, *scripted, error) {
|
||||
prov := newScripted(arm)
|
||||
ctrl, err := boot.Build(ctx, boot.Options{
|
||||
Version: "runtime-resume-probe",
|
||||
Model: probeModelRef,
|
||||
Sink: sink,
|
||||
WorkspaceRoot: root.Workspace,
|
||||
Home: root.Home,
|
||||
SessionDir: root.Sessions,
|
||||
ProviderResolver: resolverFor(prov),
|
||||
Stderr: io.Discard,
|
||||
HeadlessApprovalMode: approvalMode(arm),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ctrl.ApplyHeadlessApprovalMode(approvalMode(arm))
|
||||
return ctrl, prov, nil
|
||||
}
|
||||
|
||||
// approvalMode is the gate this arm needs. Everything else runs denied, the
|
||||
// strictest a headless frontend can be. A fleet is not read-only, so under a
|
||||
// denied gate the dispatch is refused before any child starts and the arm
|
||||
// measures a permission decision instead of a process boundary.
|
||||
func approvalMode(arm string) string {
|
||||
if graphArm(arm) || uiArm(arm) || loneTaskArm(arm) || cancelRoutingArm(arm) || lineageArm(arm) ||
|
||||
schedulerWaitArm(arm) || terminalArm(arm) || deriveArm(arm) || activeStoreArm(arm) ||
|
||||
skillArm(arm) || ephemeralArm(arm) || hostExecArm(arm) {
|
||||
return control.ToolApprovalAuto
|
||||
}
|
||||
return "deny"
|
||||
}
|
||||
|
||||
// childEnv binds the child process to this arm's home through the environment
|
||||
// as well as through Options.Home: a Controller's later re-reads and the shared
|
||||
// history index still read the process, not the assembly.
|
||||
func childEnv(root armRoot) []string {
|
||||
env := append(os.Environ(),
|
||||
"REASONIX_HOME="+root.Home,
|
||||
"REASONIX_STATE_HOME="+root.Home,
|
||||
)
|
||||
// A child runs in its own workspace, so the one arm that has to build the
|
||||
// frontend is told where the checkout is rather than searching for it.
|
||||
if repo := repoRoot(); repo != "" {
|
||||
env = append(env, envProbeRepo+"="+repo)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// repoRoot walks up from the orchestrator's working directory for the module
|
||||
// this probe lives in. Empty when it is not run from a checkout, which the arm
|
||||
// reports as not measured rather than as a failure.
|
||||
func repoRoot() string {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for {
|
||||
if b, err := os.ReadFile(filepath.Join(dir, "go.mod")); err == nil &&
|
||||
strings.HasPrefix(string(b), "module reasonix") {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return ""
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// toolIDs is every call this sink saw dispatched, which is where a host-minted
|
||||
// event id first appears: it is not in the graph and not in the transcript.
|
||||
func (s *graphSink) toolIDs() map[string]bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make(map[string]bool, len(s.tools))
|
||||
for id := range s.tools {
|
||||
out[id] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/agentgraph"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
// The scheduler-wait arms. Each dies with one item ready and refused admission,
|
||||
// and they are separate because the scheduler decides in a fixed order —
|
||||
// slots, then writers, then claim. An arm that let an earlier cause stand would
|
||||
// report it as the later one, so each has to clear every check above the one it
|
||||
// is measuring.
|
||||
const (
|
||||
// armWaitSlots fills the session's total ceiling with runs that never end.
|
||||
// The refused item is read-only: it clears every writer check by not being
|
||||
// a writer at all.
|
||||
armWaitSlots = "wait-slots"
|
||||
// armWaitWriters leaves total capacity free and fills the writer ceiling.
|
||||
// Its two writers declare disjoint paths, so a claim conflict cannot be
|
||||
// what refuses the second one.
|
||||
armWaitWriters = "wait-writers"
|
||||
// armWaitClaim leaves both ceilings free and overlaps two write paths. A
|
||||
// single fan-out cannot reach this: preflight refuses concurrent writers
|
||||
// whose claims overlap, so the holder is dispatched as a background fleet
|
||||
// and the refused writer comes from a later one.
|
||||
armWaitClaim = "wait-claim"
|
||||
// armWaitTransition asks what a wait cause means once the thing it named
|
||||
// stops being what holds the item back. The scheduler reports the cause
|
||||
// once, when the request queues; the blocker can change afterwards.
|
||||
armWaitTransition = "wait-transition"
|
||||
)
|
||||
|
||||
func schedulerWaitArm(name string) bool {
|
||||
switch name {
|
||||
case armWaitSlots, armWaitWriters, armWaitClaim, armWaitTransition:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// schedulerLimits are the ceilings an arm needs to reach its refusal. They are
|
||||
// written to the project config, so the run makes the same judgement a person
|
||||
// configuring the session would get.
|
||||
func schedulerLimits(arm string) (total, writers int) {
|
||||
switch arm {
|
||||
case armTerminalCancelled:
|
||||
// One slot, so the group has an item running and items that never got
|
||||
// one — the split a cancellation is reported against.
|
||||
return 1, 1
|
||||
case armLineageBackground, armLineageJobKill:
|
||||
// Room for a second run: the alive check asks whether the manager can
|
||||
// still start anything, and a ceiling of one would answer for the
|
||||
// hanging child instead.
|
||||
return 4, 4
|
||||
case armTaskFgQueued, armTaskBgQueued, armTaskFgQueuedCancel, armTaskBgQueuedCancel:
|
||||
// One slot, held by the fleet dispatched ahead of the delegation, so the
|
||||
// refusal the arm records is the scheduler's and not a race.
|
||||
return 1, 1
|
||||
case armUIGraphMixed:
|
||||
// One slot, so the item after the one that hangs is refused: the death
|
||||
// then holds a node that was queued and never admitted.
|
||||
return 1, 1
|
||||
case armHostQueued, armHeadlessQueuedCancel:
|
||||
// One slot, held by the fan-out dispatched ahead of the invocation.
|
||||
return 1, 1
|
||||
case armSkillQueued, armReadOnlySkillQueued:
|
||||
// One slot, held by the fleet dispatched ahead of the skill, so the
|
||||
// refusal the arm reads is the scheduler's and not a race.
|
||||
return 1, 1
|
||||
case armFleetActiveStore:
|
||||
// Two slots: the delegation holds one to the death, and the fan-out's
|
||||
// first item frees the other for exactly one of the two behind it.
|
||||
return 2, 2
|
||||
case armWaitSlots:
|
||||
return 2, 1
|
||||
case armWaitWriters:
|
||||
return 6, 1
|
||||
case armWaitClaim:
|
||||
return 6, 3
|
||||
case armWaitTransition:
|
||||
return 3, 1
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// wantedCause is the refusal this arm exists to produce. The transition arm
|
||||
// starts at slots and asks what happens to that answer afterwards.
|
||||
func wantedCause(arm string) agentgraph.WaitCause {
|
||||
switch arm {
|
||||
case armWaitWriters:
|
||||
return agentgraph.WaitWriters
|
||||
case armWaitClaim:
|
||||
return agentgraph.WaitClaim
|
||||
}
|
||||
return agentgraph.WaitSlots
|
||||
}
|
||||
|
||||
// waitingWorkers are the nodes the scheduler refused and has not admitted: the
|
||||
// arm's premise. State is checked as well as the cause, because a node that
|
||||
// went on to run still carries the cause it waited under.
|
||||
func waitingWorkers(g agentgraph.Graph, cause agentgraph.WaitCause) []string {
|
||||
var out []string
|
||||
for _, n := range g.Nodes {
|
||||
if n.Kind == agentgraph.KindGroup || n.State != agentgraph.StateQueued {
|
||||
continue
|
||||
}
|
||||
if n.Wait == cause {
|
||||
out = append(out, n.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// waitForRefusal blocks until one item is queued under this arm's cause.
|
||||
func waitForRefusal(sink *graphSink, arm string) error {
|
||||
cause := wantedCause(arm)
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
graph, _ := sink.snapshot()
|
||||
if len(waitingWorkers(graph, cause)) > 0 {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
graph, _ := sink.snapshot()
|
||||
return errUnexpected("an item queued under "+string(cause), waitStates(graph))
|
||||
}
|
||||
|
||||
func waitStates(g agentgraph.Graph) string {
|
||||
out := make([]string, 0, len(g.Nodes))
|
||||
for _, n := range g.Nodes {
|
||||
out = append(out, fmt.Sprintf("%s:%s/%s", n.ID, n.State, orNone(string(n.Wait))))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return orNone(join(out))
|
||||
}
|
||||
|
||||
// schedTurn is the prompt that opens this arm's fan-out. The slots arm needs
|
||||
// only one fleet; the others name both, and the latch order dispatches the
|
||||
// holder first while the refused fleet waits on the holder's own signal.
|
||||
func schedTurn(n int, arm string) string {
|
||||
if arm == armWaitSlots {
|
||||
return fmt.Sprintf("%s %s: dispatch the fan-out.", marker(n), fleetPairSentinel)
|
||||
}
|
||||
return fmt.Sprintf("%s %s %s: dispatch the fan-out.", marker(n), fleetHolderSentinel, fleetRefusedSentinel)
|
||||
}
|
||||
|
||||
// runSchedulerWaitConstruct drives the arm to its refusal and exits while the
|
||||
// item is still queued. The transition arm goes further: it frees the capacity
|
||||
// the refusal named and asks what the reported cause does about it.
|
||||
func runSchedulerWaitConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
|
||||
go func() { _ = ctrl.Run(ctx, schedTurn(turn+1, arm)) }()
|
||||
if err := waitForRefusal(sink, arm); err != nil {
|
||||
return err
|
||||
}
|
||||
if arm == armWaitTransition {
|
||||
if err := releaseAndSettle(sink, prov); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeObservation(root, capture("construct", arm, bootSystem, ctrl, sink, root)); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseAndSettle frees the run that filled total capacity, then waits for the
|
||||
// graph to show it gone. What the refused item's cause does after that is the
|
||||
// arm's question: the ceiling it named has room, and the writer ceiling that
|
||||
// now holds it back was never reported.
|
||||
func releaseAndSettle(sink *graphSink, prov *scripted) error {
|
||||
prov.releaseOnce()
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
graph, _ := sink.snapshot()
|
||||
if settledWorkers(graph) > 0 && len(waitingWorkers(graph, agentgraph.WaitSlots)) > 0 {
|
||||
// Give any second report a chance to arrive before reading.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
graph, _ := sink.snapshot()
|
||||
return errUnexpected("capacity freed while a refusal still stands", waitStates(graph))
|
||||
}
|
||||
|
||||
func settledWorkers(g agentgraph.Graph) int {
|
||||
n := 0
|
||||
for _, node := range g.Nodes {
|
||||
if node.Kind != agentgraph.KindGroup && node.State != "" && node.State.Terminal() {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// schedulerWaitRows report what a restart inherits about an item the scheduler
|
||||
// refused. The classification is already right — STARTED settled that — so the
|
||||
// question here is narrower: can the host still say why it never started?
|
||||
func schedulerWaitRows(arm string, before, after Observation) []row {
|
||||
rows := []row{refusalRow(arm, before, after)}
|
||||
if arm == armWaitTransition {
|
||||
rows = append(rows, capacityFreedRow(before))
|
||||
}
|
||||
return append(rows,
|
||||
waitSeriesRow(arm, before),
|
||||
executionKindRow(before, after),
|
||||
waitProvenanceRow(before, after),
|
||||
schedulerGateRow(before, after),
|
||||
)
|
||||
}
|
||||
|
||||
// capacityFreedRow is the transition arm's evidence. It freed the ceiling the
|
||||
// refusal named and the item stayed queued, held by a different one — so what
|
||||
// the reported cause does next is a question about the contract, not a race.
|
||||
func capacityFreedRow(before Observation) row {
|
||||
return row{
|
||||
Semantic: "the ceiling the refusal named, once freed", Authority: "the arm, through a child it releases",
|
||||
Artifact: "none", Reconstruction: "an item settles while the refused one stays queued",
|
||||
Before: fmt.Sprintf("%d settled, still queued: %s", before.SettledWorkers, orNone(join(before.QueuedWorkers))),
|
||||
After: "not applicable: the scheduler is gone", Verdict: verdictStable,
|
||||
}
|
||||
}
|
||||
|
||||
// refusalRow is the arm's own control: it reached the refusal it names, and no
|
||||
// earlier one. Reaching a different cause means an upper check intercepted it,
|
||||
// and the arm measured someone else's question.
|
||||
func refusalRow(arm string, before, after Observation) row {
|
||||
want := string(wantedCause(arm))
|
||||
verdict := verdictHolds
|
||||
if before.WaitCauses[want] == 0 {
|
||||
verdict = verdictNotMeasured
|
||||
}
|
||||
return row{
|
||||
Semantic: "the refusal this arm reached", Authority: "SubagentScheduler.canStartLocked",
|
||||
Artifact: "none: reported once through OnQueued", Reconstruction: "slots, then writers, then claim",
|
||||
Before: causeSummary(before), After: causeSummary(after), Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
// waitSeriesRow is what the transition arm exists for: how many times a cause
|
||||
// was published for the item that never started. Once means the answer is the
|
||||
// cause that queued it, not the one that held it last.
|
||||
func waitSeriesRow(arm string, before Observation) row {
|
||||
return row{
|
||||
Semantic: "how often a wait cause was reported", Authority: "OnQueued, which fires once per request",
|
||||
Artifact: "none", Reconstruction: causeContract(before),
|
||||
Before: seriesSummary(before), After: "not applicable: the scheduler is gone",
|
||||
Verdict: verdictStable,
|
||||
}
|
||||
}
|
||||
|
||||
// causeContract states what the observed reporting says the cause means. It is
|
||||
// read off the series rather than assumed: one report can only be the cause
|
||||
// that queued the request, and more than one would track the current blocker.
|
||||
func causeContract(o Observation) string {
|
||||
for _, seq := range o.WaitSeries {
|
||||
if len(seq) > 1 {
|
||||
return "reported again as the blocker changes: the cause is the current one"
|
||||
}
|
||||
}
|
||||
return "reported once: the cause is the one that queued the request, not the one holding it now"
|
||||
}
|
||||
|
||||
// waitProvenanceRow is what the batch was built to expose and then close. The
|
||||
// refusal exists only at the moment the scheduler makes it, so it is recorded
|
||||
// there; this compares what the graph showed against what a later process can
|
||||
// still read back.
|
||||
func waitProvenanceRow(before, after Observation) row {
|
||||
return valueRow("why the item never started", "the execution journal's queue records",
|
||||
"<stem>.execution.jsonl", "the cause the scheduler gave when it first refused admission",
|
||||
causeSummary(before), journalCauses(after), true)
|
||||
}
|
||||
|
||||
// journalCauses is the refusal a restart reads back, counted the way the graph
|
||||
// counts it so the two sides of the row are the same measurement.
|
||||
func journalCauses(o Observation) string {
|
||||
counts := map[string]int{}
|
||||
for _, e := range o.Executions {
|
||||
if e.Queued() {
|
||||
counts[e.Cause]++
|
||||
}
|
||||
}
|
||||
if len(counts) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(counts))
|
||||
for k := range counts {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// schedulerGateRow is the other half of what a queue record proves. An item
|
||||
// only reaches the scheduler once its dependencies are answered, so a queued
|
||||
// entry is durable evidence it crossed that gate — the one thing a dependency
|
||||
// edge alone could never say.
|
||||
func schedulerGateRow(before, after Observation) row {
|
||||
return valueRow("items proven to have reached the scheduler", "the execution journal's queue records",
|
||||
"<stem>.execution.jsonl", "a queue record implies the dependency gate was crossed",
|
||||
queuedIDs(before), queuedIDs(after), true)
|
||||
}
|
||||
|
||||
func queuedIDs(o Observation) string {
|
||||
var out []string
|
||||
for _, e := range o.Executions {
|
||||
if e.Queued() {
|
||||
out = append(out, e.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func causeSummary(o Observation) string {
|
||||
if len(o.WaitCauses) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(o.WaitCauses))
|
||||
for k := range o.WaitCauses {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%d", k, o.WaitCauses[k]))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func seriesSummary(o Observation) string {
|
||||
if len(o.WaitSeries) == 0 {
|
||||
return "none"
|
||||
}
|
||||
ids := make([]string, 0, len(o.WaitSeries))
|
||||
for id := range o.WaitSeries {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, id+":["+strings.Join(o.WaitSeries[id], "->")+"]")
|
||||
}
|
||||
return join(out)
|
||||
}
|
||||
|
||||
// schedulerArmInvalid refuses an arm that did not reach its own refusal. Each
|
||||
// arm exists to isolate one cause, and one that reached a different cause was
|
||||
// intercepted by a check above it — reporting that as this arm's answer is how
|
||||
// a probe measures the wrong ceiling and calls it evidence.
|
||||
func schedulerArmInvalid(arm string, before Observation) string {
|
||||
want := string(wantedCause(arm))
|
||||
if before.WaitCauses[want] == 0 {
|
||||
return "no item was refused under " + want + "; the arm reached " + orNone(causeSummary(before))
|
||||
}
|
||||
for cause := range before.WaitCauses {
|
||||
if cause != want {
|
||||
return "an item was also refused under " + cause + ", so this arm does not isolate " + want
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// newScripted builds the provider with its synchronisation channels open. The
|
||||
// scheduler arms coordinate through them; every other arm never reads them.
|
||||
func newScripted(arm string) *scripted {
|
||||
return &scripted{arm: arm, held: make(chan struct{}), release: make(chan struct{})}
|
||||
}
|
||||
|
||||
// probeModelRef is both the catalog ref and the model name boot is given, so
|
||||
// syntheticEntryFromResolver matches it without touching any config file.
|
||||
const probeModelRef = "probe/scripted"
|
||||
|
||||
// probeAltModelRef is a second identity the same scripted provider answers to.
|
||||
// Without one, every layer of the model resolution collapses onto the same
|
||||
// string and a probe cannot tell which layer produced it.
|
||||
const probeAltModelRef = "probe/alt"
|
||||
|
||||
// scripted answers completions from a fixed script so the construct phase can
|
||||
// establish host state without a network call. A request carrying no tools is a
|
||||
// host-internal completion (the compaction summary); everything else is a turn.
|
||||
type scripted struct {
|
||||
// arm is which run this provider is scripting. Only the fan-out arms read
|
||||
// it: the pair fleet is the same dispatch either way, and the arm decides
|
||||
// whether its second item finishes or is still executing at death.
|
||||
arm string
|
||||
turnCalls atomic.Int64
|
||||
// retodo latches the one reply that replaces the task list. The sentinel
|
||||
// stays in history after the call, so without it every later round would
|
||||
// rewrite the list again.
|
||||
retodo atomic.Bool
|
||||
// asked latches the one reply that opens a question and, in the same round,
|
||||
// tries to write. Calling ask ends the round, so the write is the effect a
|
||||
// decision is holding back.
|
||||
asked atomic.Bool
|
||||
// Which fleets have been opened. A fleet's aggregate returns into the same
|
||||
// round that dispatched it, so without a latch the reply that follows reads
|
||||
// its own sentinel off the still-current user turn and dispatches again.
|
||||
fleets dispatched
|
||||
// opened says the arm's own fan-out has gone out, whichever sentinel opened
|
||||
// it: the terminal and derive arms then hold the turn rather than replying,
|
||||
// so the process still dies inside one.
|
||||
opened atomic.Bool
|
||||
// The scheduler arms dispatch two fleets and must not race them. held is
|
||||
// closed by the holder's own child, which only runs once its slot is
|
||||
// granted; release lets the arm free capacity while a refusal stands.
|
||||
holding sync.Once
|
||||
released sync.Once
|
||||
held chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
// dispatched is the set of fleets already opened, keyed by the sentinel that
|
||||
// opened each. One named state rather than a latch per fleet: they share a
|
||||
// lifetime and a meaning, and every new fan-out was adding another flag.
|
||||
type dispatched struct {
|
||||
mu sync.Mutex
|
||||
seen map[string]bool
|
||||
}
|
||||
|
||||
// held reports whether something already recorded this name, without claiming
|
||||
// it. The lone-delegation arms read it to ask a negative: that a child the
|
||||
// scheduler should be holding back never reached the provider at all.
|
||||
func (d *dispatched) held(name string) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.seen[name]
|
||||
}
|
||||
|
||||
// first reports whether this is the sentinel's first dispatch, and records it.
|
||||
func (d *dispatched) first(sentinel string) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.seen[sentinel] {
|
||||
return false
|
||||
}
|
||||
if d.seen == nil {
|
||||
d.seen = map[string]bool{}
|
||||
}
|
||||
d.seen[sentinel] = true
|
||||
return true
|
||||
}
|
||||
|
||||
// releaseOnce frees the child waiting on release. Idempotent: an arm may reach
|
||||
// the moment to release through more than one path.
|
||||
func (s *scripted) releaseOnce() { s.released.Do(func() { close(s.release) }) }
|
||||
|
||||
func (s *scripted) Name() string { return "probe" }
|
||||
|
||||
func (s *scripted) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
ch := make(chan provider.Chunk, 8)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
for _, c := range s.script(ctx, req) {
|
||||
select {
|
||||
case ch <- c:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (s *scripted) script(ctx context.Context, req provider.Request) []provider.Chunk {
|
||||
if len(req.Tools) == 0 {
|
||||
return append(text(summaryBody), done())
|
||||
}
|
||||
// A delegated run answers first: the parent's transcript quotes every fleet
|
||||
// argument, so any later branch that scanned the conversation would make
|
||||
// the parent answer as one of its own children.
|
||||
if sentinel := childSentinel(req); sentinel != "" {
|
||||
return s.childScript(ctx, sentinel)
|
||||
}
|
||||
if chunks, ok := s.fanOut(req); ok {
|
||||
return chunks
|
||||
}
|
||||
if s.opens(req, sleepSentinel) {
|
||||
return append(sleepCall(), done())
|
||||
}
|
||||
if s.opensRun(req, taskSentinel) {
|
||||
if queuedTaskArm(s.arm) {
|
||||
// The holder's own child says when the ceiling is occupied.
|
||||
// Dispatching before that lets the delegation win the race and start.
|
||||
<-s.held
|
||||
}
|
||||
return append(loneTaskCall(s.arm), done())
|
||||
}
|
||||
if s.opensRun(req, ephemeralSentinel) {
|
||||
return append(ephemeralCall(s.arm), done())
|
||||
}
|
||||
if s.opensRun(req, skillSentinel) {
|
||||
if queuedSkillArm(s.arm) {
|
||||
// The holder's own child says when the ceiling is occupied.
|
||||
// Dispatching before that lets the skill win the race and start.
|
||||
<-s.held
|
||||
}
|
||||
return append(skillCall(s.arm), done())
|
||||
}
|
||||
// The one arm whose turn has to close signs the host's steps off first, one
|
||||
// per round, because a turn that called a tool cannot deliver while the list
|
||||
// is open. The latch is read last: a round that could not answer keeps it.
|
||||
if closesItsTurn(s.arm) && s.opened.Load() && hasTool(req.Tools, "complete_step") {
|
||||
for _, id := range probeStepIDs() {
|
||||
if s.fleets.first("sign-" + id) {
|
||||
return append(signStep(id), done())
|
||||
}
|
||||
}
|
||||
}
|
||||
// A terminal arm needs its fan-out to finish and its turn to stay open: a
|
||||
// closed turn would settle the marker, and the process would no longer be
|
||||
// dying inside one.
|
||||
if (terminalArm(s.arm) || deriveArm(s.arm)) && s.opened.Load() {
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
if s.turnCalls.Add(1) == 1 && hasTool(req.Tools, "todo_write") {
|
||||
return append(todoCall(firstTodos()), done())
|
||||
}
|
||||
if hasTool(req.Tools, "todo_write") && requestMentions(req, retodoSentinel) && !s.retodo.Swap(true) {
|
||||
return append(todoCall(secondTodos()), done())
|
||||
}
|
||||
if requestMentions(req, askSentinel) && !s.asked.Swap(true) {
|
||||
return append(askAndWrite(), done())
|
||||
}
|
||||
return append(text(echoMarker(req)+turnBody), done())
|
||||
}
|
||||
|
||||
// A turn is tagged twice: the user side by markerPattern, the assistant reply
|
||||
// by echoPattern. A fold folds assistant work first, so a one-sided tag would
|
||||
// let the surviving user turn hide the reply that was dropped beside it.
|
||||
var (
|
||||
markerPattern = regexp.MustCompile(`PROBE-MARK-\d+`)
|
||||
echoPattern = regexp.MustCompile(`PROBE-ECHO-\d+`)
|
||||
)
|
||||
|
||||
func marker(n int) string { return fmt.Sprintf("PROBE-MARK-%03d", n) }
|
||||
|
||||
// markersIn returns the tags a message set still shows, in order.
|
||||
func markersIn(texts []string, pat *regexp.Regexp) []string {
|
||||
var out []string
|
||||
for _, t := range texts {
|
||||
out = append(out, pat.FindAllString(t, -1)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// echoMarker answers a turn with the assistant-side tag for the same number.
|
||||
func echoMarker(req provider.Request) string {
|
||||
for _, m := range slices.Backward(req.Messages) {
|
||||
found := markerPattern.FindAllString(m.Content, -1)
|
||||
if len(found) == 0 {
|
||||
continue
|
||||
}
|
||||
return "PROBE-ECHO-" + found[len(found)-1][len("PROBE-MARK-"):] + " reply. "
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasTool(schemas []provider.ToolSchema, name string) bool {
|
||||
for _, t := range schemas {
|
||||
if t.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func text(s string) []provider.Chunk {
|
||||
return []provider.Chunk{{Type: provider.ChunkText, Text: s}}
|
||||
}
|
||||
|
||||
func done() provider.Chunk { return provider.Chunk{Type: provider.ChunkDone} }
|
||||
|
||||
// todoCall writes the list the resume phase looks for. The step ids are the
|
||||
// point: they are the host identity a completion has to cite. A first write
|
||||
// may not open with a completed item — the host rejects that shape, and a
|
||||
// rejected call would leave the row unmeasured rather than answered.
|
||||
func todoCall(todos []map[string]any) []provider.Chunk {
|
||||
args, _ := json.Marshal(map[string]any{"todos": todos})
|
||||
return []provider.Chunk{{
|
||||
Type: provider.ChunkToolCall,
|
||||
ToolCall: &provider.ToolCall{ID: "probe_todo_" + strconv.Itoa(len(todos)), Name: "todo_write", Arguments: string(args)},
|
||||
}}
|
||||
}
|
||||
|
||||
// retodoSentinel asks the scripted reply to replace the task list, so a probe
|
||||
// can move host step identity from one set of ids to another.
|
||||
const retodoSentinel = "PROBE-RETODO"
|
||||
|
||||
// askSentinel asks for a question the host must put to a person, alongside the
|
||||
// write that question is holding back.
|
||||
const (
|
||||
askSentinel = "PROBE-ASK"
|
||||
deferredEffect = "probe-deferred-effect.txt"
|
||||
deferredContents = "this write was not held back by the decision"
|
||||
)
|
||||
|
||||
// askAndWrite opens a question and asks to write in the same round. The round
|
||||
// ends at the question, so the write must not run — not while the answer is
|
||||
// pending, and not because a later process reloaded the session.
|
||||
func askAndWrite() []provider.Chunk {
|
||||
ask, _ := json.Marshal(map[string]any{"questions": []map[string]any{{
|
||||
"header": "Boundary",
|
||||
"question": "Which side of the fold should the probe measure?",
|
||||
"reason": "user_decision",
|
||||
"options": []map[string]any{
|
||||
{"label": "Below the fold", "description": "removes folded history"},
|
||||
{"label": "Above the fold", "description": "leaves coverage intact"},
|
||||
},
|
||||
}}})
|
||||
write, _ := json.Marshal(map[string]string{"path": deferredEffect, "content": deferredContents})
|
||||
return []provider.Chunk{
|
||||
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "probe_ask_1", Name: "ask", Arguments: string(ask)}},
|
||||
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "probe_write_1", Name: "write_file", Arguments: string(write)}},
|
||||
}
|
||||
}
|
||||
|
||||
func requestMentions(req provider.Request, needle string) bool {
|
||||
for _, m := range slices.Backward(req.Messages) {
|
||||
if strings.Contains(m.Content, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firstTodos() []map[string]any {
|
||||
return []map[string]any{
|
||||
{"content": "Establish probe state", "status": "in_progress", "activeForm": "Establishing probe state", "step_id": "probe_step_01"},
|
||||
{"content": "Cross the process boundary", "status": "pending", "step_id": "probe_step_02"},
|
||||
{"content": "Report the resurrection matrix", "status": "pending", "step_id": "probe_step_03"},
|
||||
}
|
||||
}
|
||||
|
||||
// secondTodos replaces the pending work while the in_progress item stays: the
|
||||
// host refuses to drop an item that is in flight, so this is what a list
|
||||
// rewrite actually looks like. The two new ids are what a stale note misses.
|
||||
func secondTodos() []map[string]any {
|
||||
return []map[string]any{
|
||||
{"content": "Establish probe state", "status": "in_progress", "activeForm": "Establishing probe state", "step_id": "probe_step_01"},
|
||||
{"content": "Split the frozen body", "status": "pending", "step_id": "probe_step_21"},
|
||||
{"content": "Hold the ordering contract", "status": "pending", "step_id": "probe_step_22"},
|
||||
}
|
||||
}
|
||||
|
||||
// turnBody is fat on purpose: the fold keeps a recent verbatim tail, so thin
|
||||
// replies leave no region between the pinned head and that tail.
|
||||
var turnBody = "Probe state established. " + strings.Repeat("The probe records deterministic filler so the fold has material to work on. ", 60)
|
||||
|
||||
var summaryBody = strings.Join([]string{
|
||||
"## Standing facts & constraints",
|
||||
"The runtime-resume probe is measuring what survives a process boundary.",
|
||||
"",
|
||||
"## Goal",
|
||||
"Establish host state, exit, and observe what a new process can still prove.",
|
||||
}, "\n")
|
||||
|
||||
func resolverFor(prov *scripted) *provider.StaticResolver {
|
||||
return &provider.StaticResolver{
|
||||
Descriptors: []provider.Descriptor{{
|
||||
Ref: probeModelRef, DisplayName: "probe", Model: "scripted",
|
||||
ContextWindow: 128_000, Tools: true,
|
||||
}, {
|
||||
Ref: probeAltModelRef, DisplayName: "probe-alt", Model: "scripted-alt",
|
||||
ContextWindow: 128_000, Tools: true,
|
||||
}},
|
||||
Providers: map[string]provider.Provider{probeModelRef: prov, probeAltModelRef: prov},
|
||||
}
|
||||
}
|
||||
|
||||
func errUnexpected(what string, got any) error {
|
||||
return fmt.Errorf("probe could not establish %s (got %v); the arm measures nothing and is reported invalid", what, got)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Sentinels are matched by substring, so one contained in another dispatches on
|
||||
// the same prompt: the identity arm silently ran the cancellation fan-out for as
|
||||
// long as PROBE-PARALLEL sat inside PROBE-PARALLEL-IDENTITY, and its rows read a
|
||||
// scenario nobody wrote. Care did not catch it — the child sentinels carry a
|
||||
// comment about exactly this hazard and the parent ones had no guard at all.
|
||||
func TestNoSentinelContainsAnother(t *testing.T) {
|
||||
sentinels := map[string]string{
|
||||
"fleetWarm": fleetWarmSentinel, "fleetMixed": fleetMixedSentinel,
|
||||
"fleetPair": fleetPairSentinel, "fleetHolder": fleetHolderSentinel,
|
||||
"fleetRefused": fleetRefusedSentinel, "fleetTerminal": fleetTerminalSentinel,
|
||||
"fleetOutcomes": fleetOutcomesSentinel, "fleetDerive": fleetDeriveSentinel,
|
||||
"fleetIdentity": fleetIdentitySentinel, "fleetActive": fleetActiveSentinel,
|
||||
"fleetSettled": fleetSettledSentinel, "fleetLive": fleetLiveSentinel,
|
||||
"parallel": parallelSentinel, "parallelIdentity": parallelIdentity,
|
||||
"task": taskSentinel, "sleep": sleepSentinel,
|
||||
"ask": askSentinel, "retodo": retodoSentinel,
|
||||
"childDone": childDone, "childHang": childHang, "childFail": childFail,
|
||||
"childHold": childHold, "childRelease": childRelease, "childFailLate": childFailLate,
|
||||
}
|
||||
for outerName, outer := range sentinels {
|
||||
for innerName, inner := range sentinels {
|
||||
if outerName == innerName {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(outer, inner) {
|
||||
t.Errorf("%s (%q) contains %s (%q): a prompt naming the first dispatches the second too",
|
||||
outerName, outer, innerName, inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,694 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/ext/skill"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
// The skill-runner classification arms. They ask what kind of execution a
|
||||
// runAs=subagent skill is, because the answer decides whether it belongs to the
|
||||
// delegation class the task and fan-out arms established or is an execution
|
||||
// class of its own. Nothing here is a fix: a classification that guessed would
|
||||
// be answered by whichever plumbing happened to exist.
|
||||
const (
|
||||
// armSkillCompleted lets a skill finish and the turn close, then asks what
|
||||
// each layer kept. It runs both entry points — the tool a model calls and
|
||||
// the controller's own — because their lineage differs by design and a row
|
||||
// that read one for the other would report intent as a defect.
|
||||
armSkillCompleted = "skill-completed"
|
||||
// armSkillRunning kills the process with the child mid-execution: the death
|
||||
// every other delegation arm takes.
|
||||
armSkillRunning = "skill-running"
|
||||
// armSkillQueued fills the ceiling first. It is the arm that says whether a
|
||||
// skill belongs to the same admission domain as everything else, which is a
|
||||
// different question from whether it leaves the same records.
|
||||
armSkillQueued = "skill-queued"
|
||||
// armSkillCancel stops a running skill through the turn that admitted it, so
|
||||
// the stop has the owner a person's Stop has.
|
||||
armSkillCancel = "skill-cancel"
|
||||
// The read-only entry point is a separate surface, not a lighter version of
|
||||
// the one above: it prepares no transcript and refuses continuation, so
|
||||
// asking it for durability would be demanding it break its own contract.
|
||||
// These three ask what it is instead.
|
||||
armReadOnlySkillCompleted = "readonly-skill-completed"
|
||||
armReadOnlySkillRunning = "readonly-skill-running"
|
||||
armReadOnlySkillQueued = "readonly-skill-queued"
|
||||
)
|
||||
|
||||
func skillArm(name string) bool {
|
||||
switch name {
|
||||
case armSkillCompleted, armSkillRunning, armSkillQueued, armSkillCancel:
|
||||
return true
|
||||
}
|
||||
return readOnlySkillArm(name)
|
||||
}
|
||||
|
||||
func readOnlySkillArm(name string) bool {
|
||||
switch name {
|
||||
case armReadOnlySkillCompleted, armReadOnlySkillRunning, armReadOnlySkillQueued:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func queuedSkillArm(name string) bool {
|
||||
return name == armSkillQueued || name == armReadOnlySkillQueued
|
||||
}
|
||||
|
||||
// runningSkillArm names the arms that die or are stopped with the child inside
|
||||
// the provider, which is the only state a durable record could be missing from.
|
||||
func runningSkillArm(name string) bool {
|
||||
return name == armSkillRunning || name == armSkillCancel || name == armReadOnlySkillRunning
|
||||
}
|
||||
|
||||
const (
|
||||
skillSentinel = "PROBE-SKILL"
|
||||
// skillCallID is the tool call the model makes. It is the identity the
|
||||
// store would join to, so the arm names it rather than letting the round
|
||||
// pick one.
|
||||
skillCallID = "probe_skill"
|
||||
// probeSkillName is the skill on disk. Not a built-in: those carry their own
|
||||
// read-only promises and review contracts, and the arm needs a plain writer
|
||||
// so the scheduler sees a claim rather than a reader.
|
||||
probeSkillName = "probe-worker"
|
||||
// hostSkillTask is what the controller's own entry point is asked to do. It
|
||||
// completes, because that arm compares two lineages rather than two deaths.
|
||||
hostSkillTask = childDone + " host-initiated skill"
|
||||
)
|
||||
|
||||
// writeProbeSkill puts a runAs=subagent skill in the workspace, where a project
|
||||
// skill lives. It is a writer: a read-only one would clear the writer and claim
|
||||
// checks by not being subject to them, and the arm could not say whether a skill
|
||||
// is admitted the way every other writer is.
|
||||
func writeProbeSkill(workspace string) error {
|
||||
dir := filepath.Join(workspace, ".reasonix", skill.SkillsDirname, probeSkillName)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body := "---\nname: " + probeSkillName + "\n" +
|
||||
"description: The probe's own subagent, which answers whatever task it is handed.\n" +
|
||||
"runAs: subagent\n---\n\nAnswer the task you are given and stop.\n"
|
||||
return os.WriteFile(filepath.Join(dir, skill.SkillFile), []byte(body), 0o644)
|
||||
}
|
||||
|
||||
// skillCall is the dispatch under measurement, made through the tool a model
|
||||
// calls. The read-only arms enter through the other tool, which is a different
|
||||
// runner rather than a flag on this one.
|
||||
func skillCall(arm string) []provider.Chunk {
|
||||
name := "run_skill"
|
||||
if readOnlySkillArm(arm) {
|
||||
name = "read_only_skill"
|
||||
}
|
||||
task := skillChildSentinel(arm) + " skill body"
|
||||
args, _ := json.Marshal(map[string]any{"name": probeSkillName, "arguments": task})
|
||||
return []provider.Chunk{{
|
||||
Type: provider.ChunkToolCall,
|
||||
ToolCall: &provider.ToolCall{ID: skillCallID, Name: name, Arguments: string(args)},
|
||||
}}
|
||||
}
|
||||
|
||||
// skillChildSentinel is how this arm's child behaves. The completed arms need one
|
||||
// that finishes; every other arm needs one that holds until the process dies.
|
||||
func skillChildSentinel(arm string) string {
|
||||
if arm == armSkillCompleted || arm == armReadOnlySkillCompleted {
|
||||
return childDone
|
||||
}
|
||||
return childHang
|
||||
}
|
||||
|
||||
// skillSentinels is what this arm's one prompt carries. The queued arms name the
|
||||
// holder as well: its ceiling has to be occupied before the skill asks for a
|
||||
// slot, or the refusal they are built to record never happens.
|
||||
func skillSentinels(arm string) string {
|
||||
if queuedSkillArm(arm) {
|
||||
return fleetHolderSentinel + " " + skillSentinel
|
||||
}
|
||||
return skillSentinel
|
||||
}
|
||||
|
||||
// runSkillClassConstruct drives one skill and dies where this arm is about. The
|
||||
// completed arms let the turn close and then run the controller's own entry
|
||||
// point, so one death holds both lineages.
|
||||
func runSkillClassConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
|
||||
prompt := fanOutTurn(turn+1, skillSentinels(arm))
|
||||
completed := arm == armSkillCompleted || arm == armReadOnlySkillCompleted
|
||||
switch {
|
||||
case completed:
|
||||
if err := ctrl.Run(ctx, prompt); err != nil {
|
||||
return fmt.Errorf("skill turn: %w", err)
|
||||
}
|
||||
if err := runHostInitiatedSkill(ctx, ctrl, arm); err != nil {
|
||||
return err
|
||||
}
|
||||
case arm == armSkillCancel:
|
||||
// A stop belongs to whoever admitted the turn, so the arm that is going
|
||||
// to be stopped is admitted the way a person's input is.
|
||||
ctrl.Send(prompt)
|
||||
if err := waitForSkill(sink, prov, arm); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
go func() { _ = ctrl.Run(ctx, prompt) }()
|
||||
if err := waitForSkill(sink, prov, arm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
stop := ""
|
||||
if arm == armSkillCancel {
|
||||
stop = stopSkill(ctrl, prov, arm)
|
||||
}
|
||||
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
|
||||
child := skillChildSentinel(arm)
|
||||
obs.Progress[probeChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(child)))}
|
||||
obs.Progress[probeSkillChildLeft] = []string{fmt.Sprint(prov.fleets.held(childLeft(child)))}
|
||||
obs.Progress[probeSkillChildCtxDone] = []string{fmt.Sprint(prov.fleets.held(childCtxDone(child)))}
|
||||
obs.Progress[probeSkillStanding] = []string{skillStanding(sink, prov)}
|
||||
if stop != "" {
|
||||
obs.Progress[probeStopReached] = []string{stop}
|
||||
}
|
||||
if err := writeObservation(root, obs); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runHostInitiatedSkill runs the same skill through the controller's own entry
|
||||
// point, which is what a slash invocation reaches. Its lineage is deliberately
|
||||
// different — the runner drops the call id for a host-initiated run — so the
|
||||
// identity row has both to compare rather than one to judge.
|
||||
func runHostInitiatedSkill(ctx context.Context, ctrl *control.Controller, arm string) error {
|
||||
if _, err := ctrl.RunSubagentProfile(ctx, probeSkillName, hostSkillTask, readOnlySkillArm(arm)); err != nil {
|
||||
return fmt.Errorf("host-initiated skill: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The records a skill's child leaves. They ride the observation because only the
|
||||
// scripted provider knows them, and a store row read without them would be
|
||||
// asking about work nothing proves ran.
|
||||
const (
|
||||
probeSkillChildLeft = "probe:skill-child-left"
|
||||
probeSkillChildCtxDone = "probe:skill-child-ctxdone"
|
||||
// probeSkillStanding is where the call itself stood: dispatched, answered,
|
||||
// and whether the ceiling was occupied. A refusal has no event of its own,
|
||||
// so this is the only positive evidence the admission row has.
|
||||
probeSkillStanding = "probe:skill-standing"
|
||||
)
|
||||
|
||||
// waitForSkill blocks until the skill stands where this arm dies. A refusal is a
|
||||
// state nothing announces, so the queued arms establish it from both sides — the
|
||||
// call went out and has not answered, the ceiling is occupied, and the skill's
|
||||
// own child never arrived — then hold still and ask again, because a child that
|
||||
// was merely slow to start would have reached the provider by then.
|
||||
func waitForSkill(sink *graphSink, prov *scripted, arm string) error {
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !queuedSkillArm(arm) {
|
||||
if prov.fleets.held(childEntered(childHang)) {
|
||||
return nil
|
||||
}
|
||||
} else if skillHeldBack(sink, prov) {
|
||||
time.Sleep(2 * time.Second)
|
||||
if !skillHeldBack(sink, prov) {
|
||||
return errUnexpected("a skill the scheduler was still holding back",
|
||||
"its child ran or its call answered")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return errUnexpected("a skill child "+skillWanted(arm), skillStanding(sink, prov))
|
||||
}
|
||||
|
||||
// skillHeldBack is the refusal expressed as facts rather than as an absence: the
|
||||
// ceiling is occupied, the call is out, it has not answered, and no child of its
|
||||
// own has reached the provider.
|
||||
func skillHeldBack(sink *graphSink, prov *scripted) bool {
|
||||
_, dispatched := sink.toolDispatched(skillCallID)
|
||||
_, answered := sink.toolResult(skillCallID)
|
||||
return prov.fleets.held(childEntered(childHold)) && dispatched && !answered &&
|
||||
!prov.fleets.held(childEntered(childHang))
|
||||
}
|
||||
|
||||
func skillStanding(sink *graphSink, prov *scripted) string {
|
||||
_, dispatched := sink.toolDispatched(skillCallID)
|
||||
_, answered := sink.toolResult(skillCallID)
|
||||
return fmt.Sprintf("ceiling=%t dispatched=%t answered=%t child=%t",
|
||||
prov.fleets.held(childEntered(childHold)), dispatched, answered,
|
||||
prov.fleets.held(childEntered(childHang)))
|
||||
}
|
||||
|
||||
func skillWanted(arm string) string {
|
||||
if queuedSkillArm(arm) {
|
||||
return "held back by a full ceiling"
|
||||
}
|
||||
return "inside the provider"
|
||||
}
|
||||
|
||||
// stopSkill stops the work the way a person does — the turn's own cancel — and
|
||||
// waits to see what that reaches. Nothing is demanded: whether a stop arrives is
|
||||
// what the arm measures, and an arm that failed when it did not would report its
|
||||
// own finding as a broken premise.
|
||||
func stopSkill(ctrl *control.Controller, prov *scripted, arm string) string {
|
||||
stopped := time.Now()
|
||||
ctrl.Cancel()
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for time.Now().Before(deadline) && ctrl.Running() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
live := "gone"
|
||||
if prov.fleets.held(childEntered(childHang)) && !prov.fleets.held(childLeft(childHang)) {
|
||||
live = "still running"
|
||||
}
|
||||
return fmt.Sprintf("turn=%s work=%s ctx=%t after=%s", turnStanding(ctrl, arm), live,
|
||||
prov.fleets.held(childCtxDone(childHang)), time.Since(stopped).Round(time.Second))
|
||||
}
|
||||
|
||||
// skillClassRows are the seven dimensions, one row each. They are separate
|
||||
// because a skill can share one domain and not another — the whole reason this
|
||||
// is a classification and not a comparison.
|
||||
func skillClassRows(arm string, before, after Observation) []row {
|
||||
return []row{
|
||||
skillAdmissionRow(arm, before),
|
||||
skillGraphRow(before),
|
||||
skillJournalRow(before, after),
|
||||
skillStoreRow(before, after),
|
||||
skillCancelRow(arm, before),
|
||||
skillRestartRow(arm, before, after),
|
||||
skillIdentityRow(arm, before, after),
|
||||
skillClassVerdictRow(arm, before, after),
|
||||
}
|
||||
}
|
||||
|
||||
const skillAuthority = "the scheduler, read through what the child did"
|
||||
|
||||
// skillAdmissionRow is the scheduler dimension. There is no refusal hook on a
|
||||
// skill's acquire, so the refusal is read the only way it can be: the ceiling was
|
||||
// proven occupied and the child never arrived, against the sibling arm where the
|
||||
// ceiling was free and it did.
|
||||
func skillAdmissionRow(arm string, before Observation) row {
|
||||
entered := firstProgress(before, probeChildEntered) == "true"
|
||||
answer := "admitted: the child reached the provider"
|
||||
verdict := verdictHolds
|
||||
switch {
|
||||
case queuedSkillArm(arm) && !entered:
|
||||
answer = "refused: the ceiling was full and the child never arrived"
|
||||
case queuedSkillArm(arm):
|
||||
answer = "not held back: the child ran with the ceiling full"
|
||||
verdict = verdictViolated
|
||||
case !entered:
|
||||
answer = "the child never arrived with capacity free"
|
||||
verdict = verdictNotMeasured
|
||||
}
|
||||
return row{
|
||||
Semantic: "does it ask the session scheduler", Authority: skillAuthority,
|
||||
Artifact: "none observed", Reconstruction: "in-process only",
|
||||
Before: answer + " [" + firstProgress(before, probeSkillStanding) + "]",
|
||||
After: "—", Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
// skillGraphRow is the live picture. A skill that never draws one is invisible
|
||||
// to every frontend while it runs, which is a product fact rather than a
|
||||
// durability one and is why it is its own row.
|
||||
func skillGraphRow(before Observation) row {
|
||||
var drawn []string
|
||||
for _, n := range before.Graph.Nodes {
|
||||
if strings.HasPrefix(n.ID, skillCallID) {
|
||||
drawn = append(drawn, n.ID+":"+string(n.State))
|
||||
}
|
||||
}
|
||||
sort.Strings(drawn)
|
||||
// The whole graph's size rides along: "the filter found nothing" and "there
|
||||
// was nothing to find" are different readings, and only the second is what
|
||||
// this row means to report.
|
||||
return row{
|
||||
Semantic: "does the live graph hold it", Authority: liveAuthority,
|
||||
Artifact: "none observed", Reconstruction: "in-process only",
|
||||
Before: fmt.Sprintf("%s (graph held %d node(s))", orNone(join(drawn)), len(before.Graph.Nodes)),
|
||||
After: "—", Verdict: presenceVerdict(len(drawn) > 0),
|
||||
}
|
||||
}
|
||||
|
||||
// presenceVerdict reports what was found without calling absence a defect. What
|
||||
// a skill owes each layer is the question; a row that answered it here would be
|
||||
// asserting the classification it exists to establish.
|
||||
func presenceVerdict(present bool) string {
|
||||
if present {
|
||||
return verdictHolds
|
||||
}
|
||||
return verdictAbsent
|
||||
}
|
||||
|
||||
const verdictAbsent = "absent"
|
||||
|
||||
// skillJournalRow reports what the journal holds. It does not go through the
|
||||
// ordinary value comparison: two empty columns are equal, and calling that
|
||||
// persisted would report an execution nothing recorded as durably recorded —
|
||||
// which is the substitution this whole probe exists to refuse.
|
||||
func skillJournalRow(before, after Observation) row {
|
||||
return presenceRow("does the journal record it", journalAuthority, "<stem>.execution.jsonl",
|
||||
"ExecutionHistory over the whole session", skillExecutions(before), skillExecutions(after))
|
||||
}
|
||||
|
||||
// presenceRow answers "is anything here" before it answers "is it the same".
|
||||
// Absence on both sides is absence, never agreement.
|
||||
func presenceRow(semantic, authority, artifact, reconstruction string, before, after []string) row {
|
||||
r := row{Semantic: semantic, Authority: authority, Artifact: artifact,
|
||||
Reconstruction: reconstruction, Before: orNone(join(before)), After: orNone(join(after))}
|
||||
switch {
|
||||
case len(before) == 0 && len(after) == 0:
|
||||
r.Verdict = verdictAbsent
|
||||
case len(after) == 0:
|
||||
r.Verdict = verdictLost
|
||||
case r.Before == r.After:
|
||||
r.Verdict = verdictPersisted
|
||||
default:
|
||||
r.Verdict = verdictLossy
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// skillExecutions are the journal entries this arm's skill could be recorded
|
||||
// under. The whole journal is read rather than a prefix: a skill records nothing
|
||||
// today, so a filter written around an id it never wrote would report absence by
|
||||
// construction.
|
||||
func skillExecutions(o Observation) []string {
|
||||
var out []string
|
||||
for _, e := range o.Executions {
|
||||
if strings.HasPrefix(e.ID, skillCallID) || strings.Contains(e.Name, probeSkillName) {
|
||||
out = append(out, e.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// skillStoreRow is the durable half. The two entry points differ here by design:
|
||||
// one prepares a transcript, the other refuses to.
|
||||
func skillStoreRow(before, after Observation) row {
|
||||
return presenceRow("what the store holds for it", storeAuthority, "subagents/<ref>.meta.json",
|
||||
"ListSubagentsByParent over the transcript stem",
|
||||
skillChildren(before), skillChildren(after))
|
||||
}
|
||||
|
||||
// skillChildren are the store's records of skill children, by the kind the
|
||||
// runner declared rather than by a name match: kind is what the store was told
|
||||
// this execution is.
|
||||
func skillChildren(o Observation) []string {
|
||||
var out []string
|
||||
for _, f := range o.Children.Facts {
|
||||
if f.Kind == "skill" {
|
||||
out = append(out, f.Name+"/"+orDash(f.ParentToolCallID)+"="+f.Status)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// skillCancelRow is what a stop reached. Three facts, kept apart: a context that
|
||||
// closed and a call that returned are different events, and work whose caller
|
||||
// stopped waiting leaves the same absence behind as work that was cancelled.
|
||||
func skillCancelRow(arm string, before Observation) row {
|
||||
if arm != armSkillCancel {
|
||||
return row{
|
||||
Semantic: "what a stop reaches", Authority: "the turn's own cancel",
|
||||
Artifact: "none observed", Reconstruction: "in-process only",
|
||||
Before: "not stopped in this arm", After: "—", Verdict: verdictNotMeasured,
|
||||
}
|
||||
}
|
||||
return row{
|
||||
Semantic: "what a stop reaches", Authority: "the turn's own cancel",
|
||||
Artifact: "subagents/<ref>.meta.json", Reconstruction: "read at one instant, after the record closed",
|
||||
Before: fmt.Sprintf("%s store=%s", firstProgress(before, probeStopReached),
|
||||
orNone(join(skillChildren(before)))),
|
||||
After: "—", Verdict: skillCancelVerdict(before),
|
||||
}
|
||||
}
|
||||
|
||||
// skillCancelVerdict asks whether the terminal the store kept is the one that
|
||||
// happened. The cause is observed, not read off a message: the child's own
|
||||
// context closed, which is what a cancellation does and what a failure does not,
|
||||
// so a record that files it as failed is naming the wrong ending.
|
||||
func skillCancelVerdict(before Observation) string {
|
||||
cancelled := firstProgress(before, probeSkillChildCtxDone) == "true"
|
||||
terminal := ""
|
||||
for _, f := range before.Children.Facts {
|
||||
if f.Kind == "skill" {
|
||||
terminal = f.Status
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case terminal == "":
|
||||
return verdictNotMeasured
|
||||
case cancelled && terminal != string(sessionstore.SubagentCancelled):
|
||||
return verdictViolated
|
||||
default:
|
||||
return verdictHolds
|
||||
}
|
||||
}
|
||||
|
||||
// skillRestartRow is what the next process can say. It is separate from the
|
||||
// store row because "a record survives" and "the execution is explicable" are
|
||||
// different claims: a completed child on disk explains nothing about a run that
|
||||
// was cut.
|
||||
func skillRestartRow(arm string, before, after Observation) row {
|
||||
stored, rebuilt, cut := skillChildren(after), skillRebuiltNodes(after), skillInterruptions(after)
|
||||
answer := fmt.Sprintf("store=%s rebuilt=%s cut=%s",
|
||||
orNone(join(stored)), orNone(join(rebuilt)), orNone(join(cut)))
|
||||
return row{
|
||||
Semantic: "what a restart can say about it", Authority: rebuildAuthority,
|
||||
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
|
||||
Reconstruction: "execgraph.Rebuild over the journal, and the store beside it",
|
||||
Before: skillAtDeath(arm, before), After: answer,
|
||||
Verdict: skillRestartVerdict(stored, rebuilt, cut),
|
||||
}
|
||||
}
|
||||
|
||||
// skillInterruptions are the interruptions that name this skill. The whole list
|
||||
// is wider than the arm: a queued arm fills its ceiling with a fan-out, and a
|
||||
// fan-out records everything — reading its interruption as the skill's is how an
|
||||
// arm reports a gap as closed by evidence belonging to something else.
|
||||
func skillInterruptions(o Observation) []string {
|
||||
var out []string
|
||||
for _, e := range o.InterruptedExecutions {
|
||||
if strings.HasPrefix(e.ID, skillCallID) || strings.Contains(e.Name, probeSkillName) {
|
||||
out = append(out, e.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// skillRestartVerdict keeps two claims apart. That the store remembers a child
|
||||
// finished is not that a restart can reconstruct the delegated execution: the
|
||||
// first is a record, the second is provenance, and an arm that folded them would
|
||||
// report a run nothing can place as recovered.
|
||||
func skillRestartVerdict(stored, rebuilt, cut []string) string {
|
||||
switch {
|
||||
case len(rebuilt) > 0 || len(cut) > 0:
|
||||
return verdictHolds
|
||||
case len(stored) > 0:
|
||||
return verdictRecordOnly
|
||||
default:
|
||||
return verdictAbsent
|
||||
}
|
||||
}
|
||||
|
||||
// verdictRecordOnly is a child the store kept for an execution no reconstruction
|
||||
// names.
|
||||
const verdictRecordOnly = "record-only"
|
||||
|
||||
// skillRebuiltNodes are the nodes a restart's rebuild draws for this skill.
|
||||
func skillRebuiltNodes(o Observation) []string {
|
||||
var out []string
|
||||
for _, n := range rebuiltGraph(o).Graph.Nodes {
|
||||
if strings.HasPrefix(n.ID, skillCallID) || strings.Contains(n.Label, probeSkillName) {
|
||||
out = append(out, n.ID+":"+string(n.State))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func skillAtDeath(arm string, before Observation) string {
|
||||
switch {
|
||||
case queuedSkillArm(arm):
|
||||
return "held back, never started"
|
||||
case firstProgress(before, probeChildEntered) != "true":
|
||||
return "never reached the provider"
|
||||
case skillFinished(before):
|
||||
return "ran and returned"
|
||||
default:
|
||||
return "executing inside the provider"
|
||||
}
|
||||
}
|
||||
|
||||
// skillIdentityRow is the seventh dimension, and it reads the two entry points
|
||||
// apart. A model-invoked skill has a call to join to; the controller's own entry
|
||||
// point drops that id on purpose, so an empty one there is top-level provenance
|
||||
// rather than a lost join — reading them together is what stops a correct value
|
||||
// being reported as a defect.
|
||||
func skillIdentityRow(arm string, before, after Observation) row {
|
||||
var got []string
|
||||
for _, f := range after.Children.Facts {
|
||||
if f.Kind != "skill" {
|
||||
continue
|
||||
}
|
||||
got = append(got, fmt.Sprintf("%s from=%s model=%s effort=%s",
|
||||
f.Name, orDash(f.ParentToolCallID), orDash(f.Model), orDash(f.Effort)))
|
||||
}
|
||||
sort.Strings(got)
|
||||
return row{
|
||||
Semantic: "what identity the record carries", Authority: storeAuthority,
|
||||
Artifact: "subagents/<ref>.meta.json",
|
||||
Reconstruction: "the call the model made, against the entry point that made it",
|
||||
Before: skillLineageExpected(arm), After: orNone(join(got)),
|
||||
Verdict: skillIdentityVerdict(after),
|
||||
}
|
||||
}
|
||||
|
||||
func skillLineageExpected(arm string) string {
|
||||
if arm == armSkillCompleted || arm == armReadOnlySkillCompleted {
|
||||
return "model-invoked joins the execution under " + skillCallID + "; host-initiated names none"
|
||||
}
|
||||
return "model-invoked joins the execution opened under " + skillCallID
|
||||
}
|
||||
|
||||
// skillIdentityVerdict holds when the model-invoked record joins the execution
|
||||
// the journal opened under the call the model made. The join is checked, never
|
||||
// one spelling of it: a record's parent is the execution's own identity, so
|
||||
// comparing it to the bare call id would fail the chain that makes them
|
||||
// joinable. A host-started run has no call to hang under, so it is not held.
|
||||
func skillIdentityVerdict(after Observation) string {
|
||||
opened := map[string]bool{}
|
||||
for _, id := range skillExecutions(after) {
|
||||
opened[id] = true
|
||||
}
|
||||
joined, records := false, 0
|
||||
for _, f := range after.Children.Facts {
|
||||
if f.Kind != "skill" {
|
||||
continue
|
||||
}
|
||||
records++
|
||||
if opened[f.ParentToolCallID] && strings.HasPrefix(f.ParentToolCallID, skillCallID) {
|
||||
joined = true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case records == 0:
|
||||
return verdictNotMeasured
|
||||
case joined:
|
||||
return verdictHolds
|
||||
default:
|
||||
return verdictViolated
|
||||
}
|
||||
}
|
||||
|
||||
// skillClassVerdictRow is the arm's answer in one line, and it is a conjunction
|
||||
// rather than a comparison: an execution the scheduler admitted, whose child
|
||||
// reached the provider, that no live picture, no journal and no active store
|
||||
// record names, is lost while it is happening — not merely unrecorded.
|
||||
func skillClassVerdictRow(arm string, before, after Observation) row {
|
||||
entered := firstProgress(before, probeChildEntered) == "true"
|
||||
graph := len(skillGraphNodes(before)) > 0
|
||||
journal := len(skillExecutions(before)) > 0
|
||||
stored := len(skillChildren(before)) > 0
|
||||
answer, verdict := "not this arm's question", verdictNotMeasured
|
||||
switch {
|
||||
case queuedSkillArm(arm):
|
||||
answer, verdict = skillQueuedNegative(before)
|
||||
case readOnlySkillArm(arm) && runningSkillArm(arm) && entered:
|
||||
answer, verdict = readOnlyClass(graph, journal, stored)
|
||||
case runningSkillArm(arm) && entered:
|
||||
if graph || journal || stored {
|
||||
answer, verdict = fmt.Sprintf("named by graph=%t journal=%t store=%t", graph, journal, stored), verdictHolds
|
||||
} else {
|
||||
answer, verdict = "LOST-SILENT: executing, and named by no layer", verdictViolated
|
||||
}
|
||||
case runningSkillArm(arm):
|
||||
answer = "the child never reached the provider"
|
||||
}
|
||||
return row{
|
||||
Semantic: "an execution in flight, named by nothing", Authority: crossAuthority,
|
||||
Artifact: "none, which is the finding",
|
||||
Reconstruction: "scheduler admitted + child entered + no graph, journal or store record",
|
||||
Before: answer, After: "—", Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
// readOnlyClass classifies the entry point that promised no durable side
|
||||
// effects. Absence there is its contract kept, not a gap — but it still holds a
|
||||
// scheduler slot, so whether anything draws it while it does is a separate
|
||||
// answer, and a durable record would be the contract broken the other way.
|
||||
func readOnlyClass(graph, journal, stored bool) (string, string) {
|
||||
switch {
|
||||
case journal || stored:
|
||||
return "an entry point that promised no durable side effects left one", verdictViolated
|
||||
case graph:
|
||||
return "ephemeral by contract, and drawn while it holds a slot", verdictEphemeralSeen
|
||||
default:
|
||||
return "ephemeral by contract, and unseen while it holds a slot", verdictEphemeralUnseen
|
||||
}
|
||||
}
|
||||
|
||||
// The two readings of an execution that keeps nothing. Neither is a defect: what
|
||||
// separates them is whether anyone can see the slot being held.
|
||||
const (
|
||||
verdictEphemeralSeen = "ephemeral-seen"
|
||||
verdictEphemeralUnseen = "ephemeral-unseen"
|
||||
)
|
||||
|
||||
// skillQueuedNegative is the control the running row needs, and it is about the
|
||||
// store alone. An opening is the orchestration saying work entered it, which a
|
||||
// refused item did; a store record is a child execution artifact, which it has
|
||||
// none of. Demanding silence from the journal too would forbid the very record
|
||||
// that tells a restart the item was held back rather than lost.
|
||||
func skillQueuedNegative(before Observation) (string, string) {
|
||||
if len(skillChildren(before)) > 0 {
|
||||
return "a child record for work the scheduler never admitted", verdictViolated
|
||||
}
|
||||
return "held back: opened and queued, with no child execution artifact", verdictHolds
|
||||
}
|
||||
|
||||
func skillGraphNodes(o Observation) []string {
|
||||
var out []string
|
||||
for _, n := range o.Graph.Nodes {
|
||||
if strings.HasPrefix(n.ID, skillCallID) {
|
||||
out = append(out, n.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// skillArmInvalid reports the premise this arm could not establish. A skill that
|
||||
// never reached the provider measures nothing about executions in flight, and a
|
||||
// ceiling that was not occupied measures nothing about admission.
|
||||
func skillArmInvalid(arm string, before Observation) string {
|
||||
entered := firstProgress(before, probeChildEntered) == "true"
|
||||
switch {
|
||||
case queuedSkillArm(arm) && entered:
|
||||
return "the skill ran while the ceiling was meant to be full"
|
||||
case runningSkillArm(arm) && !entered:
|
||||
return "the skill's child never reached the provider, so nothing was executing at the death"
|
||||
case (arm == armSkillCompleted || arm == armReadOnlySkillCompleted) && !skillFinished(before):
|
||||
return "no skill child finished, so the arm compares nothing"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func skillFinished(before Observation) bool {
|
||||
return firstProgress(before, probeSkillChildLeft) == "true"
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
// successorRows ask what happens to a recorded interruption once work carries
|
||||
// on. The derivation has no way out of "interrupted" today, so these rows are
|
||||
// there to say what that costs rather than to grade it.
|
||||
func successorRows(arm string, successor, final Observation) []row {
|
||||
return []row{{
|
||||
Semantic: "the interruption after work continued", Authority: "the adjudication log",
|
||||
Artifact: "<stem>.adjudication.jsonl", Reconstruction: "still derived from an open record with no owner",
|
||||
Before: interruptionState(successor), After: interruptionState(final), Verdict: stickiness(final),
|
||||
}, {
|
||||
Semantic: "what that turn's request carried", Authority: "control, projected per request",
|
||||
Artifact: "none: derived, never stored", Reconstruction: "the interruption block, if one still stands",
|
||||
Before: fmt.Sprintf("%d block(s)", successor.ModelSeesInterruption),
|
||||
After: fmt.Sprintf("%d block(s)", final.ModelSeesInterruption), Verdict: verdictStable,
|
||||
}, {
|
||||
Semantic: "how the barrier ended", Authority: "the adjudication log",
|
||||
Artifact: "<stem>.adjudication.jsonl", Reconstruction: "an edge is written only for a barrier a turn received",
|
||||
Before: journalSummary(successor), After: journalSummary(final), Verdict: verdictStable,
|
||||
}, {
|
||||
Semantic: "the effect the dead decision held back", Authority: "the workspace",
|
||||
Artifact: final.Deferred.MarkerPath, Reconstruction: "no successor turn may release it",
|
||||
Before: ranOrNot(successor), After: ranOrNot(final), Verdict: heldBack(successor, final),
|
||||
}}
|
||||
}
|
||||
|
||||
func interruptionState(o Observation) string {
|
||||
if len(o.Interrupted) == 0 {
|
||||
return "settled"
|
||||
}
|
||||
return fmt.Sprintf("%d still interrupted", len(o.Interrupted))
|
||||
}
|
||||
|
||||
// stickiness is the question these arms were built to ask: does an
|
||||
// interruption outlive the work that replaced it? A successor that took it over
|
||||
// leaves nothing behind for later requests to carry.
|
||||
func stickiness(final Observation) string {
|
||||
if len(final.Interrupted) == 0 && final.ModelSeesInterruption == 0 {
|
||||
return "inherited"
|
||||
}
|
||||
if len(final.Interrupted) == 0 {
|
||||
return "sticky context without an owner"
|
||||
}
|
||||
return "sticky"
|
||||
}
|
||||
|
||||
func journalSummary(o Observation) string {
|
||||
var out []string
|
||||
for _, e := range o.Journal {
|
||||
switch {
|
||||
case e.Open():
|
||||
out = append(out, e.ID+":open")
|
||||
case e.SupersededBy != "":
|
||||
out = append(out, e.ID+":"+e.Disposition+" by turn "+e.SupersededBy)
|
||||
default:
|
||||
out = append(out, e.ID+":"+e.Disposition)
|
||||
}
|
||||
}
|
||||
return orNone(join(out))
|
||||
}
|
||||
|
||||
func heldBack(successor, final Observation) string {
|
||||
if successor.Deferred.Executed || final.Deferred.Executed {
|
||||
return verdictViolated
|
||||
}
|
||||
return verdictHolds
|
||||
}
|
||||
@@ -1,533 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/agentgraph"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
// The terminal-disposition arms. SETTLED says an opening is no longer active
|
||||
// and nothing more, so these ask what that costs: two items can share every
|
||||
// durable fact and mean opposite things, and a restart that cannot tell them
|
||||
// apart has lost the answer a dependent needs, not a label a picture wanted.
|
||||
const (
|
||||
// armTerminalAdopted reuses an earlier child's answer. It never runs and
|
||||
// still holds a final answer, which is the one terminal that is answered
|
||||
// without having executed.
|
||||
armTerminalAdopted = "terminal-adopted"
|
||||
// armTerminalSkippedDep is the same lifecycle with the opposite meaning: an
|
||||
// item whose dependency failed, which never ran and holds no answer.
|
||||
armTerminalSkippedDep = "terminal-skipped-dep"
|
||||
// armTerminalCancelled is the other way an item ends without an answer. It
|
||||
// was built to reach parallel_tasks' skipped branch and found it
|
||||
// unreachable: running[i] is set when an item is dispatched, not when it is
|
||||
// admitted, so a cancellation marks every unfinished item cancelled.
|
||||
armTerminalCancelled = "terminal-cancelled"
|
||||
// armTerminalContext asks whether a context edge has to be recorded at all,
|
||||
// or already follows from the ordering and what the upstream answered.
|
||||
armTerminalContext = "terminal-context"
|
||||
// armChildTerminal compares the graph's terminal for an executed item
|
||||
// against what the sub-agent store recorded for the same child. The store
|
||||
// is the owner of "what happened to this child", so a distinction the graph
|
||||
// draws and the store does not keep is one no reconstruction can recover.
|
||||
armChildTerminal = "child-terminal"
|
||||
// armIdentitySemantics asks what a node's model and effort mean. Two
|
||||
// producers resolve them through different chains and the store resolves
|
||||
// them again, so before a rebuild can be held to them, one of those has to
|
||||
// be the fact the field carries.
|
||||
armIdentitySemantics = "identity-semantics"
|
||||
)
|
||||
|
||||
func terminalArm(name string) bool {
|
||||
switch name {
|
||||
case armTerminalAdopted, armTerminalSkippedDep, armTerminalCancelled, armTerminalContext, armChildTerminal, armIdentitySemantics:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// terminalSentinels is what this arm's one prompt carries. The adopted arm
|
||||
// needs a completed reference first, so it names the warm-up fleet too.
|
||||
func terminalSentinels(arm string) string {
|
||||
switch arm {
|
||||
case armTerminalAdopted:
|
||||
return fleetWarmSentinel + " " + fleetMixedSentinel
|
||||
case armTerminalCancelled:
|
||||
return parallelSentinel
|
||||
case armChildTerminal:
|
||||
return fleetOutcomesSentinel
|
||||
case armIdentitySemantics:
|
||||
return fleetWarmSentinel + " " + fleetIdentitySentinel + " " + parallelIdentity
|
||||
}
|
||||
return fleetTerminalSentinel
|
||||
}
|
||||
|
||||
// runTerminalConstruct lets the fan-out settle, then dies before the turn ends.
|
||||
// The settling is the point: a fan-out publishes an item's skip only in its
|
||||
// closing delta, so an arm that died mid-flight would never see one.
|
||||
func runTerminalConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, turn int) error {
|
||||
runCtx, cancelRun := context.WithCancel(ctx)
|
||||
defer cancelRun()
|
||||
go func() { _ = ctrl.Run(runCtx, fanOutTurn(turn+1, terminalSentinels(arm))) }()
|
||||
if arm == armTerminalCancelled {
|
||||
if err := cancelBeforeStart(sink, cancelRun); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if arm == armChildTerminal {
|
||||
if err := cancelAfterOutcomes(sink, cancelRun); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := waitForTerminal(sink, arm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeObservation(root, capture("construct", arm, bootSystem, ctrl, sink, root)); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cancelBeforeStart interrupts the group once one item is running and another
|
||||
// is still waiting for a slot, which is the state parallel_tasks splits on. It
|
||||
// cancels the turn's own context rather than calling Controller.Cancel: the
|
||||
// synchronous headless Run does not pass through turn admission, so the gate
|
||||
// that Cancel operates has nothing registered for it.
|
||||
func cancelBeforeStart(sink *graphSink, cancelRun context.CancelFunc) error {
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
graph, _ := sink.snapshot()
|
||||
if runningCount(graph) > 0 && len(queuedWorkers(graph)) > 0 {
|
||||
cancelRun()
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
graph, _ := sink.snapshot()
|
||||
return errUnexpected("one item running and one still queued", waitStates(graph))
|
||||
}
|
||||
|
||||
// cancelAfterOutcomes interrupts once one item has completed and another has
|
||||
// failed, with a third still running. All three terminals then come from one
|
||||
// group, so the store's record of them is compared under identical conditions.
|
||||
func cancelAfterOutcomes(sink *graphSink, cancelRun context.CancelFunc) error {
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
graph, _ := sink.snapshot()
|
||||
if len(nodesInState(graph, agentgraph.StateCompleted)) > 0 &&
|
||||
len(nodesInState(graph, agentgraph.StateFailed)) > 0 &&
|
||||
runningCount(graph) > 0 {
|
||||
cancelRun()
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
graph, _ := sink.snapshot()
|
||||
return errUnexpected("one completed, one failed and one still running", waitStates(graph))
|
||||
}
|
||||
|
||||
func runningCount(g agentgraph.Graph) int {
|
||||
n := 0
|
||||
for _, node := range g.Nodes {
|
||||
if node.Kind != agentgraph.KindGroup && node.State == agentgraph.StateRunning {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// wantedTerminal is the disposition this arm exists to produce.
|
||||
func wantedTerminal(arm string) agentgraph.NodeState {
|
||||
if arm == armTerminalAdopted {
|
||||
return agentgraph.StateAdopted
|
||||
}
|
||||
if arm == armTerminalContext {
|
||||
return agentgraph.StateCompleted
|
||||
}
|
||||
if arm == armTerminalCancelled || arm == armChildTerminal {
|
||||
return agentgraph.StateCancelled
|
||||
}
|
||||
if arm == armIdentitySemantics {
|
||||
return agentgraph.StateAdopted
|
||||
}
|
||||
return agentgraph.StateSkipped
|
||||
}
|
||||
|
||||
// waitForTerminal blocks until the graph shows this arm's disposition. The
|
||||
// identity arm needs more than a state: it compares three producers, so it
|
||||
// waits until the last of them has settled something rather than the first.
|
||||
func waitForTerminal(sink *graphSink, arm string) error {
|
||||
want := wantedTerminal(arm)
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
graph, _ := sink.snapshot()
|
||||
if len(nodesInState(graph, want)) > 0 && identityReady(graph, arm) {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
graph, _ := sink.snapshot()
|
||||
return errUnexpected("an item in state "+string(want), waitStates(graph))
|
||||
}
|
||||
|
||||
// identityReady reports whether every producer the identity arm compares has
|
||||
// published a settled item. Waiting on one of them leaves the others unread.
|
||||
func identityReady(g agentgraph.Graph, arm string) bool {
|
||||
if arm != armIdentitySemantics {
|
||||
return true
|
||||
}
|
||||
settled := map[string]int{}
|
||||
for _, n := range g.Nodes {
|
||||
if n.Kind == agentgraph.KindGroup || n.State == "" || !n.State.Terminal() {
|
||||
continue
|
||||
}
|
||||
settled[n.ParentID]++
|
||||
}
|
||||
return settled["probe_fleet_identity"] >= 4 && settled["probe_parallel_identity"] >= 2
|
||||
}
|
||||
|
||||
func nodesInState(g agentgraph.Graph, want agentgraph.NodeState) []string {
|
||||
var out []string
|
||||
for _, n := range g.Nodes {
|
||||
if n.Kind != agentgraph.KindGroup && n.State == want {
|
||||
out = append(out, n.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// terminalRows put the two lifecycles side by side. Adopted and skipped reach
|
||||
// the same durable shape by different routes and mean opposite things to a
|
||||
// dependent, so every row here compares what the graph knew against what the
|
||||
// journal can still be asked.
|
||||
func terminalRows(arm string, before, after Observation) []row {
|
||||
rows := []row{
|
||||
terminalStateRow(arm, before, after),
|
||||
terminalLifecycleRow(before, after),
|
||||
terminalAnswerRow(before, after),
|
||||
}
|
||||
if arm == armTerminalAdopted {
|
||||
rows = append(rows, adoptSourceRow(before, after))
|
||||
}
|
||||
if arm == armTerminalContext || arm == armTerminalSkippedDep {
|
||||
rows = append(rows, contextDerivabilityRow(before, after))
|
||||
}
|
||||
if arm == armChildTerminal {
|
||||
rows = append(rows, childOutcomeRow(before, after))
|
||||
}
|
||||
if arm == armIdentitySemantics {
|
||||
rows = append(rows, identitySemanticsRow(before), identityRebuildRow(before, after))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// terminalStateRow is the arm's own control and the loss in one line: which
|
||||
// disposition the graph ended on, and whether anything still says so.
|
||||
func terminalStateRow(arm string, before, after Observation) row {
|
||||
want := wantedTerminal(arm)
|
||||
verdict := verdictNotMeasured
|
||||
if len(nodesInState(graphOf(before), want)) > 0 {
|
||||
verdict = verdictHolds
|
||||
}
|
||||
return row{
|
||||
Semantic: "the disposition this arm reached", Authority: "the fan-out's closing delta",
|
||||
Artifact: "none observed", Reconstruction: "published once, when the group ends",
|
||||
Before: terminalSummary(before), After: terminalSummary(after), Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
// terminalLifecycleRow is the comparison that decides whether a disposition has
|
||||
// to be recorded. Two items with the same durable lifecycle and opposite
|
||||
// meanings cannot be told apart by anything a restart reads.
|
||||
func terminalLifecycleRow(before, after Observation) row {
|
||||
return valueRow("the durable lifecycle each item left", "the execution journal",
|
||||
"<stem>.execution.jsonl", "opened / queued / started / settled, per item",
|
||||
lifecycleSummary(before), lifecycleSummary(after), true)
|
||||
}
|
||||
|
||||
// terminalAnswerRow is what a dependent actually needs. Completed and adopted
|
||||
// carry a final answer a dependent may start from; skipped and cancelled do
|
||||
// not, and the difference is the whole reason the distinction exists.
|
||||
func terminalAnswerRow(before, after Observation) row {
|
||||
return valueRow("items holding an answer a dependent may use", "agentgraph.NodeState.Answered",
|
||||
"none observed", "derived from the disposition, which is not recorded",
|
||||
answeredSummary(before), answeredSummary(after), false)
|
||||
}
|
||||
|
||||
// adoptSourceRow is the provenance an adoption carries: whose answer stood in.
|
||||
// Reuse is only visible if the source is nameable — otherwise the picture shows
|
||||
// work that cost nothing with no way to say what paid for it. The comparison is
|
||||
// the graph's edge against the journal's opening, so a record that named a
|
||||
// different source would read as a loss rather than as agreement.
|
||||
func adoptSourceRow(before, after Observation) row {
|
||||
return valueRow("whose answer an adoption reused", "the adopt edge, recorded with the opening",
|
||||
"<stem>.execution.jsonl", "read back with the openings, never re-derived",
|
||||
adoptEdges(before), journalAdoptions(after), true)
|
||||
}
|
||||
|
||||
// journalAdoptions renders the openings' recorded sources the way the graph
|
||||
// renders its edges, so the two sides of the row are the same measurement.
|
||||
func journalAdoptions(o Observation) string {
|
||||
var out []string
|
||||
for _, e := range o.Executions {
|
||||
if e.AdoptedFrom != "" {
|
||||
out = append(out, e.ID+"<-"+e.AdoptedFrom)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
// contextDerivabilityRow asks whether the delivery edge follows from facts that
|
||||
// are already durable. A context edge that always accompanies an ordering edge
|
||||
// whose upstream answered is derivable; one that does not is a fact of its own.
|
||||
func contextDerivabilityRow(before, after Observation) row {
|
||||
implied, actual := impliedContext(before), contextEdges(before)
|
||||
verdict := verdictHolds
|
||||
if implied != actual {
|
||||
verdict = verdictViolated
|
||||
}
|
||||
return row{
|
||||
Semantic: "whether a context edge follows from the durable facts",
|
||||
Authority: "the ordering edges and what each upstream answered",
|
||||
Artifact: "<stem>.execution.jsonl for the ordering; the disposition is not recorded",
|
||||
Reconstruction: "an ordering edge whose upstream answered delivered its answer",
|
||||
Before: "implied " + orNone(implied) + " / actual " + orNone(actual),
|
||||
After: "implied " + orNone(impliedContext(after)) + " / actual: not observable",
|
||||
Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
// executedTerminals are the graph's terminals for items that actually ran.
|
||||
// Adopted and skipped are excluded: nothing executed under them, so the store
|
||||
// owes them no record and counting them would read a correct store as short.
|
||||
func executedTerminals(o Observation) map[string]int {
|
||||
out := map[string]int{}
|
||||
for _, n := range o.Graph.Nodes {
|
||||
if n.Kind == agentgraph.KindGroup || n.State == "" || !n.State.Terminal() {
|
||||
continue
|
||||
}
|
||||
if n.State == agentgraph.StateAdopted || n.State == agentgraph.StateSkipped {
|
||||
continue
|
||||
}
|
||||
out[string(n.State)]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// childOutcomeRow compares the two owners of the same fact. The graph draws
|
||||
// completed, failed and cancelled apart; the store is what a restart reads. A
|
||||
// terminal the graph distinguishes and the store folds into another is gone for
|
||||
// good — no journal record could recover it, because the owner never kept it.
|
||||
func childOutcomeRow(before, after Observation) row {
|
||||
graphTerminals := executedTerminals(before)
|
||||
verdict := verdictNotMeasured
|
||||
if len(graphTerminals) > 0 {
|
||||
verdict = verdictHolds
|
||||
for state := range graphTerminals {
|
||||
if !storeKnows(after, state) {
|
||||
verdict = verdictViolated
|
||||
}
|
||||
}
|
||||
}
|
||||
return row{
|
||||
Semantic: "terminal outcome: what the graph drew, what the store kept",
|
||||
Authority: "the graph's closing delta against SubagentStore.Meta.Status",
|
||||
Artifact: "subagents/<ref>.meta.json",
|
||||
Reconstruction: "the store owns what happened to a child; every terminal it draws it must keep",
|
||||
Before: "graph " + countSummary(graphTerminals) + " / store " + orNone(childStatusCounts(before)),
|
||||
After: "store " + orNone(childStatusCounts(after)), Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
// storeKnows reports whether any child carries this terminal as its status.
|
||||
func storeKnows(o Observation, state string) bool {
|
||||
for _, f := range o.Children.Facts {
|
||||
if f.Status == state {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func childStatusCounts(o Observation) string {
|
||||
counts := map[string]int{}
|
||||
for _, f := range o.Children.Facts {
|
||||
counts[f.Status]++
|
||||
}
|
||||
if len(counts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return countSummary(counts)
|
||||
}
|
||||
|
||||
// identitySemanticsRow lays the three answers beside each other: what the
|
||||
// caller named, what the graph shows, and what the store resolved. They are not
|
||||
// expected to agree — the point is which of them a node's field is reporting,
|
||||
// because a rebuild cannot be held to a field whose meaning is unsettled.
|
||||
func identitySemanticsRow(before Observation) row {
|
||||
store := map[string]string{}
|
||||
for _, f := range before.Children.Facts {
|
||||
store[f.ParentToolCallID] = orNone(f.Model) + "/" + orNone(f.Effort)
|
||||
}
|
||||
var out []string
|
||||
for _, n := range before.Graph.Nodes {
|
||||
if n.Kind != agentgraph.KindWorker {
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s graph=%s/%s store=%s", n.ID,
|
||||
orNone(n.Model), orNone(n.Effort), orNone(store[n.ID])))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return row{
|
||||
Semantic: "worker identity: what each layer reports", Authority: "the fan-out's spec against the store's resolution",
|
||||
Artifact: "subagents/<ref>.meta.json", Reconstruction: "the graph resolves a subagent chain; the store resolves the final identity",
|
||||
Before: join(out), After: "not applicable: read before the boundary", Verdict: verdictStable,
|
||||
}
|
||||
}
|
||||
|
||||
// identityRebuildRow is the same question asked of the rebuild: whichever of
|
||||
// those layers the field is meant to carry, does a restart get it back?
|
||||
func identityRebuildRow(before, after Observation) row {
|
||||
return valueRow("worker identity after a restart", "execgraph.Rebuild over journal + store",
|
||||
"<stem>.execution.jsonl + subagents/<ref>.meta.json",
|
||||
"the journal records no worker identity; the store records the one it resolved",
|
||||
identitySummary(graphOf(before)), identitySummary(rebuiltGraph(after).Graph), false)
|
||||
}
|
||||
|
||||
func graphOf(o Observation) agentgraph.Graph {
|
||||
return agentgraph.Graph{Nodes: o.Graph.Nodes, Edges: o.Graph.Edges}
|
||||
}
|
||||
|
||||
func terminalSummary(o Observation) string {
|
||||
if len(o.Graph.Nodes) == 0 {
|
||||
return ""
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, n := range o.Graph.Nodes {
|
||||
if n.Kind != agentgraph.KindGroup && n.State != "" {
|
||||
counts[string(n.State)]++
|
||||
}
|
||||
}
|
||||
return countSummary(counts)
|
||||
}
|
||||
|
||||
// lifecycleSummary renders each item's durable transitions, which is what a
|
||||
// restart actually has. Two items printing the same line here are two items a
|
||||
// restart cannot tell apart.
|
||||
func lifecycleSummary(o Observation) string {
|
||||
if len(o.Executions) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := make([]string, 0, len(o.Executions))
|
||||
for _, e := range o.Executions {
|
||||
marks := []string{"open"}
|
||||
if e.Disposition != "" && e.Disposition != "pending" {
|
||||
marks = append(marks, e.Disposition)
|
||||
}
|
||||
if e.Queued() {
|
||||
marks = append(marks, "queued("+e.Cause+")")
|
||||
}
|
||||
if e.Started() {
|
||||
marks = append(marks, "started")
|
||||
}
|
||||
if !e.SettledAt.IsZero() {
|
||||
marks = append(marks, "settled")
|
||||
}
|
||||
out = append(out, e.ID+":"+strings.Join(marks, "+"))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func answeredSummary(o Observation) string {
|
||||
var out []string
|
||||
for _, n := range o.Graph.Nodes {
|
||||
if n.Kind != agentgraph.KindGroup && n.State.Answered() {
|
||||
out = append(out, n.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func adoptEdges(o Observation) string {
|
||||
var out []string
|
||||
for _, e := range o.Graph.Edges {
|
||||
if e.Kind == agentgraph.Adopt {
|
||||
out = append(out, e.To+"<-"+e.From)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func contextEdges(o Observation) string {
|
||||
var out []string
|
||||
for _, e := range o.Graph.Edges {
|
||||
if e.Kind == agentgraph.Context {
|
||||
out = append(out, e.To+"<-"+e.From)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
// impliedContext is what the durable facts alone would predict: every ordering
|
||||
// edge whose upstream ended up answered.
|
||||
func impliedContext(o Observation) string {
|
||||
answered := map[string]bool{}
|
||||
for _, n := range o.Graph.Nodes {
|
||||
if n.State.Answered() {
|
||||
answered[n.ID] = true
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
for _, e := range o.Executions {
|
||||
for _, up := range e.DependsOn {
|
||||
if answered[up] {
|
||||
out = append(out, e.ID+"<-"+up)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func countSummary(counts map[string]int) string {
|
||||
keys := make([]string, 0, len(counts))
|
||||
for k := range counts {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// terminalArmInvalid refuses an arm that never reached its disposition. Every
|
||||
// row below it compares against a shape that was not established, and reporting
|
||||
// those as evidence is how a probe answers a question nobody asked.
|
||||
func terminalArmInvalid(arm string, before Observation) string {
|
||||
want := wantedTerminal(arm)
|
||||
if len(nodesInState(graphOf(before), want)) == 0 {
|
||||
return "no item reached " + string(want) + "; the fan-out ended as " + orNone(terminalSummary(before))
|
||||
}
|
||||
if arm == armTerminalContext && contextEdges(before) == "" {
|
||||
return "the fan-out delivered no upstream answer, so there is no context edge to derive"
|
||||
}
|
||||
if arm == armTerminalCancelled && len(nodesInState(graphOf(before), agentgraph.StateSkipped)) > 0 {
|
||||
return "an item was skipped rather than cancelled; parallel_tasks' skipped branch was thought unreachable"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Contract verdicts for an arm that asserts a fact rather than comparing two
|
||||
// readings: the note either satisfies what todoIdentityProjection claims about
|
||||
// itself, or it does not.
|
||||
const (
|
||||
verdictHolds = "holds"
|
||||
verdictViolated = "VIOLATED"
|
||||
)
|
||||
|
||||
// todoRows judge the host's step-identity note against the three things it
|
||||
// claims: there is one of it, it rides the turn tail, and it names the identity
|
||||
// the host holds now. Each is checked on both sides of the process boundary.
|
||||
func todoRows(before, after Observation) []row {
|
||||
return []row{
|
||||
todoRow("host identity notes in the view", "one note when the history lost the ids, none when it kept them",
|
||||
before, after, noteCount, exactlyOnce),
|
||||
todoRow("where the identity note sits", "the note rides the live tail, as a turn-tail note",
|
||||
before, after, notePlacement, ridesTheTail),
|
||||
todoRow("the identity the note carries", "the note names the host's current step ids",
|
||||
before, after, noteIdentity, matchesHost),
|
||||
}
|
||||
}
|
||||
|
||||
func todoRow(semantic, contract string, before, after Observation,
|
||||
render func(Observation) string, holds func(Observation) bool) row {
|
||||
verdict := verdictHolds
|
||||
switch {
|
||||
case before.TodoNotes.ViewLen == 0 || after.TodoNotes.ViewLen == 0:
|
||||
verdict = verdictNotMeasured
|
||||
case !holds(before) || !holds(after):
|
||||
verdict = verdictViolated
|
||||
}
|
||||
return row{
|
||||
Semantic: semantic, Authority: "agent todoState (host-owned)",
|
||||
Artifact: "none: derived for each request, stored nowhere",
|
||||
Reconstruction: contract,
|
||||
Before: render(before), After: render(after), Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
func noteCount(o Observation) string {
|
||||
owed := "history lost the ids"
|
||||
if o.TodoNotes.ReadableInHistory {
|
||||
owed = "history still shows them"
|
||||
}
|
||||
return fmt.Sprintf("%d (%s)", o.TodoNotes.Count, owed)
|
||||
}
|
||||
|
||||
// exactlyOnce is conditional on need: a request whose history still shows the
|
||||
// ids is owed nothing, and demanding a note there would turn the migration into
|
||||
// an unconditional append.
|
||||
func exactlyOnce(o Observation) bool {
|
||||
if o.TodoNotes.ReadableInHistory {
|
||||
return o.TodoNotes.Count == 0
|
||||
}
|
||||
return o.TodoNotes.Count == 1
|
||||
}
|
||||
|
||||
// notePlacement reports the note's index against the end of the frozen body.
|
||||
// An index below it means the note was frozen ahead of messages the splice adds
|
||||
// after it, which is the opposite of riding the tail.
|
||||
func notePlacement(o Observation) string {
|
||||
n := o.TodoNotes
|
||||
if n.Count == 0 {
|
||||
return "no note"
|
||||
}
|
||||
where := "after the live tail"
|
||||
if n.Indexes[len(n.Indexes)-1] < n.BodyLen {
|
||||
where = "inside the frozen body"
|
||||
}
|
||||
return fmt.Sprintf("index %v of %d, body ends at %d — %s", n.Indexes, n.ViewLen, n.BodyLen, where)
|
||||
}
|
||||
|
||||
// ridesTheTail passes vacuously when no note is owed: placement is a claim
|
||||
// about a note that exists.
|
||||
func ridesTheTail(o Observation) bool {
|
||||
n := o.TodoNotes
|
||||
if n.Count == 0 {
|
||||
return n.ReadableInHistory
|
||||
}
|
||||
return n.Indexes[len(n.Indexes)-1] >= n.BodyLen
|
||||
}
|
||||
|
||||
func noteIdentity(o Observation) string {
|
||||
n := o.TodoNotes
|
||||
return fmt.Sprintf("note %s / host %s", join(dedupe(n.IDs)), join(n.HostIDs))
|
||||
}
|
||||
|
||||
func matchesHost(o Observation) bool {
|
||||
n := o.TodoNotes
|
||||
if n.Count == 0 {
|
||||
return n.ReadableInHistory
|
||||
}
|
||||
return len(n.HostIDs) > 0 && slices.Equal(dedupe(n.IDs), n.HostIDs)
|
||||
}
|
||||
|
||||
func dedupe(in []string) []string {
|
||||
var out []string
|
||||
for _, s := range in {
|
||||
if !slices.Contains(out, s) {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/agentgraph"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/frontend/serve"
|
||||
"reasonix/internal/session/control"
|
||||
"reasonix/internal/state/execjournal"
|
||||
)
|
||||
|
||||
// armUIGraphMixed is the only arm that reads the frontend rather than the host.
|
||||
// Everything below it has been shown to rebuild the graph exactly; this asks
|
||||
// the question a value comparison cannot: through which door did each fact
|
||||
// reach the view? A history replayed as live deltas settles on the same state
|
||||
// and tells the user work is starting that ended in a process now dead.
|
||||
const armUIGraphMixed = "ui-graph-mixed"
|
||||
|
||||
func uiArm(name string) bool { return name == armUIGraphMixed }
|
||||
|
||||
// The two extra fleets this arm's turn opens. The settled one ends before the
|
||||
// death, which is the only way a skip exists at all — a fan-out publishes one in
|
||||
// its closing delta — and the live one is dispatched by the resumed process.
|
||||
const (
|
||||
fleetSettledSentinel = "PROBE-FLEET-SETTLED"
|
||||
fleetLiveSentinel = "PROBE-FLEET-LIVE"
|
||||
)
|
||||
|
||||
// envProbeSabotage names a deliberate defect to run under. The arm must go red
|
||||
// with it and green without: a probe nobody has watched fail is a decoration.
|
||||
const envProbeSabotage = "REASONIX_PROBE_SABOTAGE"
|
||||
|
||||
// envProbeRepo carries the checkout to the child that has to build the observer.
|
||||
// A child's working directory is its own workspace, so nothing else in the
|
||||
// process can find the frontend.
|
||||
const envProbeRepo = "REASONIX_PROBE_REPO"
|
||||
|
||||
const (
|
||||
sabotagePublish = "publish"
|
||||
sabotageTrajectory = "trajectory"
|
||||
)
|
||||
|
||||
// settledFleet runs to completion with its dependent skipped, so the death
|
||||
// inherits a terminal the mid-flight fleet cannot produce.
|
||||
func settledFleet() []provider.Chunk {
|
||||
return fleetCall("probe_fleet_settled", []map[string]any{
|
||||
{"id": "up", "prompt": childFail + " settled upstream", "description": "fails", "read_only": true},
|
||||
{"id": "down", "prompt": childDone + " settled downstream", "description": "skipped by up",
|
||||
"read_only": true, "depends_on": []string{"up"}},
|
||||
})
|
||||
}
|
||||
|
||||
// uiMixedFleet is the density fleet plus one item the session ceiling refuses,
|
||||
// so the death also holds a node that was queued and never admitted.
|
||||
func uiMixedFleet(adoptRef string) []provider.Chunk {
|
||||
return fleetCall("probe_fleet_mixed", []map[string]any{
|
||||
{"id": "m1", "prompt": childDone + " mixed one", "description": "completes", "read_only": true},
|
||||
{"id": "m2", "prompt": childFail + " mixed two", "description": "fails", "read_only": true},
|
||||
{"id": "m3", "prompt": childDone + " mixed three", "description": "blocked by m2",
|
||||
"read_only": true, "depends_on": []string{"m2"}},
|
||||
{"id": "m4", "adopt_ref": adoptRef, "description": "adopted"},
|
||||
{"id": "m5", "prompt": childHang + " mixed five", "description": "running at death",
|
||||
"write_paths": []string{"probe-fanout-out"}},
|
||||
{"id": "m6", "prompt": childHang + " mixed six", "description": "queued at death", "read_only": true},
|
||||
})
|
||||
}
|
||||
|
||||
// liveFleet is what the resumed process dispatches while the view is watching.
|
||||
// Without it a frontend that had stopped folding deltas entirely would pass
|
||||
// every other row here.
|
||||
func liveFleet() []provider.Chunk {
|
||||
return fleetCall("probe_fleet_live", []map[string]any{
|
||||
{"id": "l1", "prompt": childDone + " live one", "description": "starts after the resume", "read_only": true},
|
||||
{"id": "l2", "prompt": childDone + " live two", "description": "starts after the resume", "read_only": true},
|
||||
})
|
||||
}
|
||||
|
||||
// livePrompt opens that fleet from the frontend, through /submit.
|
||||
func livePrompt() string { return fanOutTurn(200, fleetLiveSentinel) }
|
||||
|
||||
// uiBus is the broadcaster the one arm with a frontend needs beside the probe's
|
||||
// own fold: in the dying process it is what records the wire log a window would
|
||||
// have recorded, and in the resumed one it is what the client reads.
|
||||
func uiBus(arm string, sink *graphSink) (*serve.Broadcaster, event.Sink) {
|
||||
if !uiArm(arm) {
|
||||
return nil, sink
|
||||
}
|
||||
bc := serve.NewBroadcaster()
|
||||
return bc, teeSink{to: []event.Sink{sink, bc}}
|
||||
}
|
||||
|
||||
// recordLikeAWindow attaches the wire log the trajectory is read from. The
|
||||
// server is never served: the constructor is what subscribes the recorder, and
|
||||
// the dying process needs the record, not a socket.
|
||||
func recordLikeAWindow(ctrl control.SessionAPI, bc *serve.Broadcaster) {
|
||||
if bc != nil {
|
||||
_ = serve.New(ctrl, bc, config.ServeConfig{})
|
||||
}
|
||||
}
|
||||
|
||||
// teeSink lets one runtime's events reach both the probe's fold and the server
|
||||
// the frontend reads. Nothing production does this; the arm needs the same
|
||||
// stream on two surfaces.
|
||||
type teeSink struct{ to []event.Sink }
|
||||
|
||||
func (t teeSink) Emit(e event.Event) {
|
||||
for _, s := range t.to {
|
||||
s.Emit(e)
|
||||
}
|
||||
}
|
||||
|
||||
// UIEntry is one state the view passed through, as the frontend's own store
|
||||
// reports it. States is what the graph showed, keyed by node.
|
||||
type UIEntry struct {
|
||||
Phase string `json:"phase"`
|
||||
Origin string `json:"origin"`
|
||||
SessionID string `json:"sessionId"`
|
||||
States map[string]string `json:"states"`
|
||||
Interruptions []UIInterruption `json:"interruptions"`
|
||||
IdentityUnknown []string `json:"identityUnknown"`
|
||||
}
|
||||
|
||||
type UIInterruption struct {
|
||||
Execution string `json:"execution"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// UIObs is the whole observation sequence, not a final value: the defect this
|
||||
// arm exists to catch agrees with the host on every final value.
|
||||
type UIObs struct {
|
||||
Trace []UIEntry `json:"trace,omitempty"`
|
||||
// Deltas is what each publication the view folded named. A republished
|
||||
// history moves no visible state and is still the kernel saying that work
|
||||
// which ended in a dead process is happening now.
|
||||
Deltas [][]string `json:"deltas,omitempty"`
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
// observeUI serves the resumed session on a loopback listener and runs the real
|
||||
// client against it. The server is the production one and the client is the
|
||||
// production one; only the shell between them is the probe's, which is what
|
||||
// lets the trace be read as what a user could have seen.
|
||||
func observeUI(root armRoot, ctrl control.SessionAPI, bc *serve.Broadcaster, obs Observation) UIObs {
|
||||
ln, err := serve.ListenLoopback()
|
||||
if err != nil {
|
||||
return UIObs{Err: "listen: " + err.Error()}
|
||||
}
|
||||
defer ln.Close()
|
||||
srv := serve.New(ctrl, bc, config.ServeConfig{})
|
||||
handler := srv.Handler()
|
||||
if os.Getenv(envProbeSabotage) == sabotagePublish {
|
||||
handler = republishOnce(handler, func() { publishRebuiltGraph(bc, obs) })
|
||||
}
|
||||
go func() { _ = http.Serve(ln, handler) }()
|
||||
|
||||
bundle, err := bundleObserver(root)
|
||||
if err != nil {
|
||||
return UIObs{Err: err.Error()}
|
||||
}
|
||||
out, runErr := runObserver(bundle, serve.LoopbackOrigin(ln))
|
||||
seen := parseTrace(out)
|
||||
if runErr != nil && seen.Err == "" {
|
||||
seen.Err = runErr.Error()
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
// bundleObserver builds the observer with the frontend's own toolchain. It is
|
||||
// the client's code, imported the way the app imports it — a hand-written stand
|
||||
// -in would be measuring the probe.
|
||||
func bundleObserver(root armRoot) (string, error) {
|
||||
repo := strings.TrimSpace(os.Getenv(envProbeRepo))
|
||||
if repo == "" {
|
||||
return "", fmt.Errorf("no checkout named in %s, so the frontend could not be built", envProbeRepo)
|
||||
}
|
||||
dir := filepath.Join(repo, "desktop", "frontend-next")
|
||||
esbuild := filepath.Join(dir, "node_modules", ".bin", "esbuild")
|
||||
if _, err := os.Stat(esbuild); err != nil {
|
||||
return "", fmt.Errorf("the frontend's toolchain is not installed: %w", err)
|
||||
}
|
||||
out := filepath.Join(root.Dir, "graph-observer.mjs")
|
||||
cmd := exec.Command(esbuild, filepath.Join("src", "e2e", "graph_observer.ts"),
|
||||
"--bundle", "--platform=node", "--format=esm", "--outfile="+out)
|
||||
cmd.Dir = dir
|
||||
if msg, err := cmd.CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("bundle the observer: %w: %s", err, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func runObserver(bundle, base string) ([]byte, error) {
|
||||
cmd := exec.Command("node", bundle, "--base", base, "--prompt", livePrompt(),
|
||||
"--sabotage", os.Getenv(envProbeSabotage))
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
// parseTrace reads the observer's last line. Anything it printed before that is
|
||||
// the frontend's own noise, which belongs on the terminal and not in the row.
|
||||
func parseTrace(out []byte) UIObs {
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
var obs UIObs
|
||||
if len(lines) == 0 {
|
||||
return UIObs{Err: "the observer printed nothing"}
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lines[len(lines)-1]), &obs); err != nil {
|
||||
return UIObs{Err: "the observer's answer did not parse: " + err.Error()}
|
||||
}
|
||||
return obs
|
||||
}
|
||||
|
||||
// republishOnce fires the sabotage after a client has read the snapshot, which
|
||||
// is the only window where it can be seen at all: what a resume publishes before
|
||||
// that is numbered below the watermark the client then resumes past, and the
|
||||
// bootstrap absorbs it. The defect this models is the Studio path — a pane that
|
||||
// is already attached when a session is resumed under it.
|
||||
func republishOnce(next http.Handler, fire func()) http.Handler {
|
||||
var once sync.Once
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r)
|
||||
if r.URL.Path == "/execution-graph" {
|
||||
once.Do(func() { go fire() })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// publishRebuiltGraph is the sabotage itself: a resume that helpfully republishes
|
||||
// what it rebuilt, which is how a view gets told that finished work is starting
|
||||
// now. Production must never do this, and the arm's own rows are what says so.
|
||||
func publishRebuiltGraph(sink event.Sink, obs Observation) {
|
||||
rebuilt := rebuiltGraph(obs)
|
||||
for _, n := range rebuilt.Graph.Nodes {
|
||||
sink.Emit(event.Event{Kind: event.GraphDelta, Graph: &agentgraph.Delta{Nodes: []agentgraph.Node{n}}})
|
||||
}
|
||||
if len(rebuilt.Graph.Edges) > 0 {
|
||||
sink.Emit(event.Event{Kind: event.GraphDelta, Graph: &agentgraph.Delta{Edges: rebuilt.Graph.Edges}})
|
||||
}
|
||||
// The publication has to reach the socket before the client attaches, or the
|
||||
// sabotage would be measuring a race instead of a defect.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
// historicalIDs is every node the dying process had drawn, groups included: a
|
||||
// group redrawn as running is the same lie about the same dead process.
|
||||
func historicalIDs(o Observation) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, n := range o.Graph.Nodes {
|
||||
out[n.ID] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstSight is the origin each node carried the first time the view held it.
|
||||
func firstSight(trace []UIEntry) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, e := range trace {
|
||||
for id := range e.States {
|
||||
if _, seen := out[id]; !seen {
|
||||
out[id] = e.Origin
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var liveStates = map[string]bool{"pending": true, "queued": true, "running": true}
|
||||
|
||||
// ghostSightings are the historical nodes the view ever drew as someone's work
|
||||
// in progress. One render tick is enough: the user saw it.
|
||||
func ghostSightings(trace []UIEntry, historical map[string]bool) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, e := range trace {
|
||||
for id, state := range e.States {
|
||||
if historical[id] && liveStates[state] {
|
||||
seen[id+":"+state] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
// rebirths are nodes the view lost and introduced again. A snapshot replace is
|
||||
// not a rebirth; leaving a state and coming back inside one session is.
|
||||
func rebirths(trace []UIEntry) []string {
|
||||
gone, twice := map[string]bool{}, map[string]bool{}
|
||||
held := map[string]bool{}
|
||||
for _, e := range trace {
|
||||
if e.Phase == "loading" {
|
||||
gone, held = map[string]bool{}, map[string]bool{}
|
||||
continue
|
||||
}
|
||||
for id := range held {
|
||||
if _, still := e.States[id]; !still {
|
||||
gone[id] = true
|
||||
}
|
||||
}
|
||||
for id := range e.States {
|
||||
if gone[id] {
|
||||
twice[id] = true
|
||||
}
|
||||
}
|
||||
held = map[string]bool{}
|
||||
for id := range e.States {
|
||||
held[id] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(twice)
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// wantedInterruptions is what the host says, formatted the way the view reports
|
||||
// it. The row compares the two rather than a hand-written expectation.
|
||||
func wantedInterruptions(o Observation) string {
|
||||
var out []string
|
||||
for _, i := range rebuiltGraph(o).Interrupted {
|
||||
kind := execjournal.InterruptedBeforeStart
|
||||
if i.Started {
|
||||
kind = execjournal.InterruptedDuringExecution
|
||||
}
|
||||
out = append(out, i.Execution+":"+kind)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
|
||||
func shownInterruptions(trace []UIEntry) string {
|
||||
if len(trace) == 0 {
|
||||
return ""
|
||||
}
|
||||
var out []string
|
||||
for _, i := range trace[len(trace)-1].Interruptions {
|
||||
out = append(out, i.Execution+":"+i.Kind)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return join(out)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// The rows this arm exists for. Every other graph row compares values; these
|
||||
// compare doors. A frontend that rebuilt the same graph by replaying history as
|
||||
// live transitions would pass all of those and fail all of these.
|
||||
const uiAuthority = "the frontend's own store, read through the real transport"
|
||||
|
||||
func uiRows(before, after Observation) []row {
|
||||
ui := after.UI
|
||||
if ui == nil || len(ui.Trace) == 0 {
|
||||
return []row{uiRow("the view's observation sequence", "a sequence of visible states",
|
||||
orNone(uiFailure(ui)), verdictNotMeasured)}
|
||||
}
|
||||
historical := historicalIDs(before)
|
||||
first := firstSight(ui.Trace)
|
||||
return []row{
|
||||
uiFirstPictureRow(ui.Trace),
|
||||
uiHistoryOriginRow(historical, first),
|
||||
uiGhostRow(ui.Trace, historical),
|
||||
uiInterruptionRow(ui.Trace, after),
|
||||
uiLiveRow(historical, first),
|
||||
uiRebirthRow(ui.Trace),
|
||||
uiRepublishRow(historical, ui.Deltas),
|
||||
}
|
||||
}
|
||||
|
||||
func uiFailure(ui *UIObs) string {
|
||||
if ui == nil {
|
||||
return ""
|
||||
}
|
||||
return ui.Err
|
||||
}
|
||||
|
||||
func uiRow(semantic, want, got, verdict string) row {
|
||||
return row{
|
||||
Semantic: semantic, Authority: uiAuthority, Artifact: "none — a sequence, not a value",
|
||||
Reconstruction: want, Before: want, After: got, Verdict: verdict,
|
||||
}
|
||||
}
|
||||
|
||||
func held(ok bool) string {
|
||||
if ok {
|
||||
return verdictHolds
|
||||
}
|
||||
return verdictViolated
|
||||
}
|
||||
|
||||
// uiFirstPictureRow reads the handover itself: nothing, then the authority. A
|
||||
// graph that appears from a delta was assembled out of transitions, whatever it
|
||||
// settles on afterwards.
|
||||
func uiFirstPictureRow(trace []UIEntry) row {
|
||||
got := trace[0].Phase
|
||||
for _, e := range trace {
|
||||
if len(e.States) > 0 {
|
||||
got = trace[0].Phase + " → " + e.Origin
|
||||
break
|
||||
}
|
||||
}
|
||||
return uiRow("the first graph a resumed view draws", "loading → snapshot", got, held(got == "loading → snapshot"))
|
||||
}
|
||||
|
||||
// uiHistoryOriginRow is the arm's whole point: an execution the dead process
|
||||
// opened may enter the view only as part of an answer, never as news.
|
||||
func uiHistoryOriginRow(historical map[string]bool, first map[string]string) row {
|
||||
var wrong []string
|
||||
seen := 0
|
||||
for id, origin := range first {
|
||||
if !historical[id] {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
if origin != "snapshot" {
|
||||
wrong = append(wrong, id+" via "+origin)
|
||||
}
|
||||
}
|
||||
sort.Strings(wrong)
|
||||
got := fmt.Sprintf("%d from the snapshot", seen)
|
||||
if len(wrong) > 0 {
|
||||
got = join(wrong)
|
||||
}
|
||||
return uiRow("how work from the dead process entered the view",
|
||||
fmt.Sprintf("%d executions, each from the snapshot", len(historical)), got, held(len(wrong) == 0))
|
||||
}
|
||||
|
||||
// uiGhostRow watches every frame, not the last one: a node shown as running for
|
||||
// a single render and corrected afterwards was still shown as running.
|
||||
func uiGhostRow(trace []UIEntry, historical map[string]bool) row {
|
||||
ghosts := ghostSightings(trace, historical)
|
||||
return uiRow("a dead run shown as work in progress",
|
||||
"never pending, queued or running", orNone(join(ghosts)), held(len(ghosts) == 0))
|
||||
}
|
||||
|
||||
// uiInterruptionRow compares the two lists that describe the same executions:
|
||||
// what the host derived, and what the view told the user.
|
||||
func uiInterruptionRow(trace []UIEntry, after Observation) row {
|
||||
want, got := wantedInterruptions(after), shownInterruptions(trace)
|
||||
return uiRow("interruptions the view names", orNone(want), orNone(got), held(want == got))
|
||||
}
|
||||
|
||||
// uiLiveRow is the positive control. Without it a view that had stopped folding
|
||||
// deltas altogether would satisfy every row above.
|
||||
func uiLiveRow(historical map[string]bool, first map[string]string) row {
|
||||
var wrong, fresh []string
|
||||
for id, origin := range first {
|
||||
if historical[id] {
|
||||
continue
|
||||
}
|
||||
fresh = append(fresh, id)
|
||||
if origin != "delta" {
|
||||
wrong = append(wrong, id+" via "+origin)
|
||||
}
|
||||
}
|
||||
sort.Strings(fresh)
|
||||
sort.Strings(wrong)
|
||||
switch {
|
||||
case len(fresh) == 0:
|
||||
return uiRow("work started after the resume", "at least one, from a delta",
|
||||
"nothing new was ever drawn", verdictViolated)
|
||||
case len(wrong) > 0:
|
||||
return uiRow("work started after the resume", "each from a delta", join(wrong), verdictViolated)
|
||||
}
|
||||
return uiRow("work started after the resume", "each from a delta",
|
||||
fmt.Sprintf("%d from a delta: %s", len(fresh), join(fresh)), verdictHolds)
|
||||
}
|
||||
|
||||
// uiRebirthRow: a node the view dropped and drew again is a second birth to
|
||||
// whatever the interface hangs off an introduction, however idempotent the fold
|
||||
// underneath it was.
|
||||
func uiRebirthRow(trace []UIEntry) row {
|
||||
twice := rebirths(trace)
|
||||
return uiRow("a node introduced twice in one session",
|
||||
"each node introduced once", orNone(join(twice)), held(len(twice) == 0))
|
||||
}
|
||||
|
||||
// uiRepublishRow is the rule underneath the first-appearance one, and the only
|
||||
// one that catches a republication at a view that already holds the snapshot:
|
||||
// after a resume, no delta may name work the dead process opened. Nothing in
|
||||
// production produces one — an execution belongs to the process that opened it.
|
||||
func uiRepublishRow(historical map[string]bool, deltas [][]string) row {
|
||||
named := map[string]bool{}
|
||||
for _, ids := range deltas {
|
||||
for _, id := range ids {
|
||||
if historical[id] {
|
||||
named[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
old := sortedKeys(named)
|
||||
return uiRow("deltas naming work the dead process opened",
|
||||
"none: history is answered, never published", orNone(join(old)), held(len(old) == 0))
|
||||
}
|
||||
|
||||
// uiArmInvalid guards the premise. The states the death has to hold are the
|
||||
// whole reason this arm can say anything about ghosts, and an observer that
|
||||
// never ran leaves every row below it describing nothing.
|
||||
func uiArmInvalid(before, after Observation) string {
|
||||
if after.UI == nil || len(after.UI.Trace) == 0 {
|
||||
return "the frontend was never observed: " + orNone(uiFailure(after.UI))
|
||||
}
|
||||
var missing []string
|
||||
for _, state := range wantedStates(armUIGraphMixed) {
|
||||
if !holdsState(before, string(state)) {
|
||||
missing = append(missing, string(state))
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return "the death did not hold " + join(missing) + ", so the view had nothing to get wrong about them"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func holdsState(o Observation, want string) bool {
|
||||
for _, n := range o.Graph.Nodes {
|
||||
if string(n.State) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// The shapes the probe exists to tell apart, written out rather than run: a
|
||||
// full arm takes three processes and a live kernel, which is the wrong place to
|
||||
// find out that a verdict was computed backwards.
|
||||
|
||||
func historyOf(ids ...string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
out[id] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func entry(phase, origin string, states map[string]string) UIEntry {
|
||||
return UIEntry{Phase: phase, Origin: origin, States: states}
|
||||
}
|
||||
|
||||
func TestAHistoryReadBackHoldsEveryRow(t *testing.T) {
|
||||
historical := historyOf("g", "g/1", "g/2")
|
||||
trace := []UIEntry{
|
||||
entry("loading", "reset", nil),
|
||||
entry("live", "snapshot", map[string]string{"g": "", "g/1": "completed", "g/2": "failed"}),
|
||||
entry("live", "delta", map[string]string{"g": "", "g/1": "completed", "g/2": "failed", "n/1": "running"}),
|
||||
entry("live", "delta", map[string]string{"g": "", "g/1": "completed", "g/2": "failed", "n/1": "completed"}),
|
||||
}
|
||||
first := firstSight(trace)
|
||||
for _, r := range []row{
|
||||
uiFirstPictureRow(trace),
|
||||
uiHistoryOriginRow(historical, first),
|
||||
uiGhostRow(trace, historical),
|
||||
uiLiveRow(historical, first),
|
||||
uiRebirthRow(trace),
|
||||
uiRepublishRow(historical, [][]string{{"n/1"}}),
|
||||
} {
|
||||
if r.Verdict != verdictHolds {
|
||||
t.Errorf("%s = %s (%s), want %s", r.Semantic, r.Verdict, r.After, verdictHolds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The defect the arm was built for: the same final state, assembled out of
|
||||
// transitions. Every value row elsewhere in this probe would still be green.
|
||||
func TestHistoryArrivingAsNewsIsCaught(t *testing.T) {
|
||||
historical := historyOf("g/1")
|
||||
trace := []UIEntry{
|
||||
entry("loading", "reset", nil),
|
||||
entry("live", "delta", map[string]string{"g/1": "running"}),
|
||||
entry("live", "delta", map[string]string{"g/1": "completed"}),
|
||||
}
|
||||
first := firstSight(trace)
|
||||
if got := uiFirstPictureRow(trace); got.Verdict != verdictViolated {
|
||||
t.Errorf("first picture = %s, want %s", got.Verdict, verdictViolated)
|
||||
}
|
||||
if got := uiHistoryOriginRow(historical, first); got.Verdict != verdictViolated {
|
||||
t.Errorf("history origin = %s, want %s", got.Verdict, verdictViolated)
|
||||
}
|
||||
if got := uiGhostRow(trace, historical); got.Verdict != verdictViolated {
|
||||
t.Errorf("ghost = %s, want %s", got.Verdict, verdictViolated)
|
||||
}
|
||||
}
|
||||
|
||||
// A snapshot the view holds and a delta that names the same work again: the
|
||||
// state never moves, and the kernel still said finished work is happening.
|
||||
func TestRepublishedHistoryIsCaughtWithoutMovingTheState(t *testing.T) {
|
||||
historical := historyOf("g/1")
|
||||
trace := []UIEntry{
|
||||
entry("loading", "reset", nil),
|
||||
entry("live", "snapshot", map[string]string{"g/1": "completed"}),
|
||||
entry("live", "delta", map[string]string{"g/1": "completed"}),
|
||||
}
|
||||
if got := uiHistoryOriginRow(historical, firstSight(trace)); got.Verdict != verdictHolds {
|
||||
t.Fatalf("history origin = %s, want %s: the snapshot did arrive first", got.Verdict, verdictHolds)
|
||||
}
|
||||
if got := uiRepublishRow(historical, [][]string{{"g/1"}}); got.Verdict != verdictViolated {
|
||||
t.Errorf("republication = %s, want %s", got.Verdict, verdictViolated)
|
||||
}
|
||||
}
|
||||
|
||||
// A view that stopped folding deltas would satisfy every rule above, which is
|
||||
// what the positive control is for.
|
||||
func TestAViewThatNeverMovesAgainIsCaught(t *testing.T) {
|
||||
historical := historyOf("g/1")
|
||||
trace := []UIEntry{
|
||||
entry("loading", "reset", nil),
|
||||
entry("live", "snapshot", map[string]string{"g/1": "completed"}),
|
||||
}
|
||||
if got := uiLiveRow(historical, firstSight(trace)); got.Verdict != verdictViolated {
|
||||
t.Errorf("live control = %s (%s), want %s", got.Verdict, got.After, verdictViolated)
|
||||
}
|
||||
}
|
||||
|
||||
// A node the view dropped and drew again, which is a second introduction to
|
||||
// anything the interface hangs off one.
|
||||
func TestANodeIntroducedTwiceIsCaught(t *testing.T) {
|
||||
trace := []UIEntry{
|
||||
entry("live", "snapshot", map[string]string{"g/1": "completed"}),
|
||||
entry("live", "delta", map[string]string{}),
|
||||
entry("live", "delta", map[string]string{"g/1": "completed"}),
|
||||
}
|
||||
if got := uiRebirthRow(trace); got.Verdict != verdictViolated {
|
||||
t.Errorf("rebirth = %s (%s), want %s", got.Verdict, got.After, verdictViolated)
|
||||
}
|
||||
}
|
||||
|
||||
// A session switch is not a rebirth: the view is told to forget, and what comes
|
||||
// back is the next conversation's first picture.
|
||||
func TestASwitchIsNotARebirth(t *testing.T) {
|
||||
trace := []UIEntry{
|
||||
entry("live", "snapshot", map[string]string{"g/1": "completed"}),
|
||||
entry("loading", "reset", map[string]string{}),
|
||||
entry("live", "snapshot", map[string]string{"g/1": "completed"}),
|
||||
}
|
||||
if got := uiRebirthRow(trace); got.Verdict != verdictHolds {
|
||||
t.Errorf("rebirth across a switch = %s (%s), want %s", got.Verdict, got.After, verdictHolds)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import "fmt"
|
||||
// arm costs more: did the extra agents produce verified work, or just tokens.
|
||||
func renderDelegation(results []result) string {
|
||||
var runs, nested, childCalls, parentCalls, mutations, dupes int
|
||||
var reports, prose, falseDone, downgrades, violations int
|
||||
var violations int
|
||||
var scopeHints, namedFiles, evidencePaths, discoveredPaths int
|
||||
solved, total, childTokens := 0, 0, 0
|
||||
for _, r := range results {
|
||||
@@ -24,10 +24,6 @@ func renderDelegation(results []result) string {
|
||||
parentCalls += r.ToolCalls - r.SubagentToolCalls
|
||||
mutations += r.SubagentMutations
|
||||
dupes += r.DuplicateWorkPaths
|
||||
reports += r.CompletionReports
|
||||
prose += r.CompletionsProsedOnly
|
||||
falseDone += r.FalseCompletions
|
||||
downgrades += r.CriterionDowngrades
|
||||
violations += r.WriteScopeViolations
|
||||
scopeHints += r.ParentScopeHints
|
||||
namedFiles += r.ParentNamedFiles
|
||||
@@ -70,11 +66,6 @@ func renderDelegation(results []result) string {
|
||||
if dupes > 0 {
|
||||
b += fmt.Sprintf("- **duplicate work**: %d file(s) mutated by more than one child\n", dupes)
|
||||
}
|
||||
closed := reports + prose
|
||||
b += fmt.Sprintf("- closed with a checkable claim: **%d/%d** (%s)\n", reports, closed, pct(reports, closed))
|
||||
if falseDone > 0 {
|
||||
b += fmt.Sprintf("- **false completions**: %d run(s), %d criterion claim(s) the host refused\n", falseDone, downgrades)
|
||||
}
|
||||
if violations > 0 {
|
||||
b += fmt.Sprintf("- **write-scope violations**: %d\n", violations)
|
||||
}
|
||||
|
||||
@@ -27,9 +27,7 @@ func TestRenderDelegationExposesWhatDelegationCost(t *testing.T) {
|
||||
runMetrics: runMetrics{
|
||||
ToolCalls: 30, SubagentToolCalls: 24,
|
||||
SubagentRuns: 3, SubagentNestedRuns: 1, SubagentMutations: 5,
|
||||
DuplicateWorkPaths: 2,
|
||||
CompletionReports: 2, CompletionsProsedOnly: 1,
|
||||
FalseCompletions: 1, CriterionDowngrades: 3,
|
||||
DuplicateWorkPaths: 2,
|
||||
WriteScopeViolations: 1,
|
||||
},
|
||||
}})
|
||||
@@ -38,8 +36,6 @@ func TestRenderDelegationExposesWhatDelegationCost(t *testing.T) {
|
||||
"parent **6** tool calls",
|
||||
"children **24**",
|
||||
"duplicate work**: 2",
|
||||
"checkable claim: **2/3**",
|
||||
"false completions**: 1 run(s), 3 criterion",
|
||||
"write-scope violations**: 1",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -92,14 +88,11 @@ func TestRenderDelegationSaysWhenEvidenceOriginIsUnscored(t *testing.T) {
|
||||
func TestRenderDelegationStaysQuietWhenNothingWentWrong(t *testing.T) {
|
||||
got := renderDelegation([]result{{
|
||||
Passed: true,
|
||||
runMetrics: runMetrics{ToolCalls: 12, SubagentToolCalls: 8, SubagentRuns: 2, CompletionReports: 2},
|
||||
runMetrics: runMetrics{ToolCalls: 12, SubagentToolCalls: 8, SubagentRuns: 2},
|
||||
}})
|
||||
for _, unwanted := range []string{"duplicate work", "false completions", "write-scope violations"} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Errorf("clean run reported %q:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "checkable claim: **2/2**") {
|
||||
t.Errorf("clean run lost its completion coverage:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -243,11 +243,11 @@ func renderDiff(r diffReport) string {
|
||||
fmt.Fprintf(&b, "| Tokens (prompt / completion) | %s / %s |\n", comma(r.m.PromptTokens), comma(r.m.CompletionTokens))
|
||||
fmt.Fprintf(&b, "| Model calls | %d |\n", r.m.Steps)
|
||||
fmt.Fprintf(&b, "| Cost | %s%.4f |\n", currencySym(r.m.Currency), r.m.Cost)
|
||||
if r.m.CapabilityRoutes > 0 || r.m.CapabilitySkillInvocations > 0 || r.m.CapabilityMCPCall > 0 || r.m.ReadinessChecks > 0 {
|
||||
if r.m.CapabilityRoutes > 0 || r.m.CapabilitySkillInvocations > 0 || r.m.CapabilityMCPCall > 0 {
|
||||
fmt.Fprintf(&b, "| Capability routes (semantic) | %d (%d) |\n", r.m.CapabilityRoutes, r.m.CapabilitySemanticRoutes)
|
||||
fmt.Fprintf(&b, "| Routed candidates (require / prefer / suggest / declined) | %d (%d / %d / %d / %d) |\n", r.m.CapabilityRoutedCandidates, r.m.CapabilityRoutedRequire, r.m.CapabilityRoutedPrefer, r.m.CapabilityRoutedSuggest, r.m.CapabilityDeclines)
|
||||
fmt.Fprintf(&b, "| Skill invocations / MCP proxy calls | %d / %d |\n", r.m.CapabilitySkillInvocations, r.m.CapabilityMCPCall)
|
||||
fmt.Fprintf(&b, "| Review blocks / readiness recoveries | %d / %d |\n", r.m.CapabilityReviewBlocks, r.m.ReadinessRecoveries)
|
||||
fmt.Fprintf(&b, "| Review blocks | %d |\n", r.m.CapabilityReviewBlocks)
|
||||
if r.m.CapabilityRouterCost > 0 || r.m.CapabilityRouterLatencyMs > 0 {
|
||||
fmt.Fprintf(&b, "| Capability-router cost / latency | %s%.4f / %dms |\n", currencySym(r.m.Currency), r.m.CapabilityRouterCost, r.m.CapabilityRouterLatencyMs)
|
||||
}
|
||||
|
||||
+5
-11
@@ -66,15 +66,11 @@ type runMetrics struct {
|
||||
|
||||
// Delegation counters mirror internal/frontend/cli.RunMetrics. They are what makes a
|
||||
// single-agent arm comparable against a delegated one for the same model.
|
||||
SubagentRuns int `json:"subagent_runs,omitempty"`
|
||||
SubagentNestedRuns int `json:"subagent_nested_runs,omitempty"`
|
||||
SubagentMutations int `json:"subagent_mutations,omitempty"`
|
||||
CompletionReports int `json:"completion_reports,omitempty"`
|
||||
CompletionsProsedOnly int `json:"completions_prose_only,omitempty"`
|
||||
FalseCompletions int `json:"false_completions,omitempty"`
|
||||
CriterionDowngrades int `json:"criterion_downgrades,omitempty"`
|
||||
WriteScopeViolations int `json:"write_scope_violations,omitempty"`
|
||||
DuplicateWorkPaths int `json:"duplicate_work_paths,omitempty"`
|
||||
SubagentRuns int `json:"subagent_runs,omitempty"`
|
||||
SubagentNestedRuns int `json:"subagent_nested_runs,omitempty"`
|
||||
SubagentMutations int `json:"subagent_mutations,omitempty"`
|
||||
WriteScopeViolations int `json:"write_scope_violations,omitempty"`
|
||||
DuplicateWorkPaths int `json:"duplicate_work_paths,omitempty"`
|
||||
// Evidence origin: what the parent's own delegation text scoped and named,
|
||||
// and how much of what the children looked at they had to find themselves.
|
||||
ParentScopeHints int `json:"parent_scope_hints,omitempty"`
|
||||
@@ -85,8 +81,6 @@ type runMetrics struct {
|
||||
// graph, which is where a scheduling wait is recorded and nowhere else.
|
||||
Fanout *fanoutMetrics `json:"fanout,omitempty"`
|
||||
// Optional Delivery capability counters (omitempty for baseline/old metrics).
|
||||
ReadinessChecks int `json:"readiness_checks,omitempty"`
|
||||
ReadinessRecoveries int `json:"readiness_recoveries,omitempty"`
|
||||
CapabilityRoutes int `json:"capability_routes,omitempty"`
|
||||
CapabilityRoutedCandidates int `json:"capability_routed_candidates,omitempty"`
|
||||
CapabilityRoutedRequire int `json:"capability_routed_require,omitempty"`
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
!macro rxScript
|
||||
FileOpen $0 "$PLUGINSDIR\rxclose.ps1" w
|
||||
FileWrite $0 "param([string]$$Dir, [string]$$Mode, [int]$$Self)$\r$\n"
|
||||
FileWrite $0 "$$names = @('${APP_EXECUTABLE_FILENAME}', 'reasonix-studio-host.exe', 'cua-driver.exe')$\r$\n"
|
||||
FileWrite $0 "$$names = @('${APP_EXECUTABLE_FILENAME}', 'reasonix-studio-host.exe')$\r$\n"
|
||||
FileWrite $0 "$$hit = @(Get-CimInstance -ClassName Win32_Process | ? { $$_.ProcessId -ne $$Self -and (($$_.Path -and $$_.Path.StartsWith($$Dir, 'CurrentCultureIgnoreCase')) -or (-not $$_.Path -and $$names -contains $$_.Name)) })$\r$\n"
|
||||
FileWrite $0 "if ($$Mode -eq 'find') { if ($$hit.Count -gt 0) { exit 0 } else { exit 1 } }$\r$\n"
|
||||
FileWrite $0 "$$hit | % { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }$\r$\n"
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"launcher-live": "node test/launcher_live.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "44.7.2",
|
||||
"electron": "45.1.0",
|
||||
"electron-builder": "^26.18.0",
|
||||
"playwright": "1.64.1"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// Exit 0 when the artifact is missing or any file under the given paths is
|
||||
// newer than it, 1 when it is up to date. run-studio.sh asks this before
|
||||
// rebuilding; the shell has no portable `find` on every platform it runs on.
|
||||
const [artifact, ...inputs] = process.argv.slice(2);
|
||||
if (!artifact) process.exit(2);
|
||||
|
||||
let built;
|
||||
try {
|
||||
built = fs.statSync(artifact).mtimeMs;
|
||||
} catch {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const SKIP = new Set(["node_modules", ".git", "dist"]);
|
||||
|
||||
function newer(entry) {
|
||||
let info;
|
||||
try {
|
||||
info = fs.statSync(entry);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!info.isDirectory()) return info.mtimeMs > built;
|
||||
for (const name of fs.readdirSync(entry)) {
|
||||
if (SKIP.has(name)) continue;
|
||||
if (newer(path.join(entry, name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
process.exit(inputs.some(newer) ? 0 : 1);
|
||||
Generated
+5
-5
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: 44.7.2
|
||||
version: 44.7.2([email protected])
|
||||
specifier: 45.1.0
|
||||
version: 45.1.0([email protected])
|
||||
electron-builder:
|
||||
specifier: ^26.18.0
|
||||
version: 26.18.0([email protected])([email protected])
|
||||
@@ -374,8 +374,8 @@ packages:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
electron@44.7.2:
|
||||
resolution: {integrity: sha512-u3Pbz0Hi1sSSwBMz2v8LiagsfUG4QURPUe/qphM+g4THKP8o4BYdnuboyhlLO8HIC/KfV5rvltyacbP4jEq9yw==}
|
||||
electron@45.1.0:
|
||||
resolution: {integrity: sha512-i7qKVAh/KR5v1pf56NhGGX2QYHcdjvLgS7koL6ui50c+zUDGLRunR95nXcTpHPURKOQ4GDBG8BmXiDgOQ8DD0A==}
|
||||
engines: {node: '>= 22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1551,7 +1551,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@44.7.2([email protected]):
|
||||
electron@45.1.0([email protected]):
|
||||
dependencies:
|
||||
'@electron-internal/extract-zip': 1.0.5
|
||||
'@electron/get': 5.1.0([email protected])
|
||||
|
||||
@@ -12,11 +12,29 @@ if [ ! -d "$here/node_modules" ]; then
|
||||
(cd "$here" && pnpm install --frozen-lockfile)
|
||||
fi
|
||||
|
||||
echo "==> frontend"
|
||||
(cd "$here/../frontend-next" && pnpm install --frozen-lockfile >/dev/null && pnpm build >/dev/null)
|
||||
# A step is skipped when nothing it reads is newer than what it produced;
|
||||
# STUDIO_REBUILD=1 forces every step.
|
||||
stale() {
|
||||
[ -n "${STUDIO_REBUILD:-}" ] || node "$here/packaging/stale.js" "$@"
|
||||
}
|
||||
|
||||
echo "==> kernel"
|
||||
(cd "$here" && pnpm build:host)
|
||||
root="$here/../.."
|
||||
web="$here/../frontend-next"
|
||||
host=$(ls "$here"/bin/reasonix-studio-host* 2>/dev/null | head -n 1 || true)
|
||||
|
||||
if stale "$web/dist/index.html" "$web/src" "$web/public" "$web/index.html" "$web/package.json" "$web/pnpm-lock.yaml" "$web/vite.config.ts" "$web/tsconfig.json"; then
|
||||
echo "==> frontend"
|
||||
(cd "$web" && pnpm install --frozen-lockfile >/dev/null && pnpm build >/dev/null)
|
||||
else
|
||||
echo "==> frontend (up to date)"
|
||||
fi
|
||||
|
||||
if [ -z "$host" ] || stale "$host" "$root/internal" "$root/cmd" "$root/go.mod" "$root/go.sum" "$here/packaging"; then
|
||||
echo "==> kernel"
|
||||
(cd "$here" && pnpm build:host)
|
||||
else
|
||||
echo "==> kernel (up to date)"
|
||||
fi
|
||||
|
||||
echo "==> studio"
|
||||
cd "$here" && exec pnpm start
|
||||
@@ -0,0 +1,22 @@
|
||||
const net = require("node:net");
|
||||
|
||||
function probeHost(origin, timeoutMs = 4000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(origin);
|
||||
const socket = net.connect({ host: url.hostname, port: Number(url.port) });
|
||||
const fail = (cause) => {
|
||||
socket.destroy();
|
||||
const err = new Error(`the host at ${origin} did not accept a connection from the shell (${cause})`);
|
||||
err.code = "unreachable";
|
||||
reject(err);
|
||||
};
|
||||
socket.setTimeout(timeoutMs, () => fail(`no answer in ${timeoutMs / 1000}s`));
|
||||
socket.once("error", (e) => fail(e.code || e.message));
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { probeHost };
|
||||
@@ -39,6 +39,7 @@ const { processMemoryHandler } = require("./procmem");
|
||||
const { captureHandler } = require("./capture");
|
||||
const { createPowerGuard, keepsAwake } = require("./powerguard");
|
||||
const { openLogs, redactArgv, failStartup } = require("./shelllog");
|
||||
const { probeHost } = require("./hostprobe");
|
||||
const { raise } = require("./raise");
|
||||
const { keepCredential, restoreIfStale } = require("./credential");
|
||||
const { actHandler } = require("./acts");
|
||||
@@ -223,6 +224,7 @@ cua = await startEmbedded(computerDriver, contract, text => logs.host.raw(text))
|
||||
tray = installTray(client, { onOpen: showWindow, onQuit: () => app.quit() });
|
||||
win.on("close", onWindowClose);
|
||||
hostAgentBrowser();
|
||||
await probeHost(origin);
|
||||
await win.loadURL(origin + PAGE_PATH);
|
||||
await tray?.refresh();
|
||||
// After the window, deliberately. This asks about an install left behind by
|
||||
|
||||
@@ -172,6 +172,10 @@ const HINTS = {
|
||||
"The kernel program could not be run. Security software blocking or locking it is a common cause.",
|
||||
"无法运行内核程序。安全软件拦截或占用它是常见原因。",
|
||||
],
|
||||
unreachable: [
|
||||
"The kernel is running, but this program cannot connect to it. A firewall or security product that blocks inbound connections for programs without an allow rule is the usual cause on Windows: allow Reasonix Studio and its reasonix-studio-host program, then start again.",
|
||||
"内核已在运行,但本程序连不上它。在 Windows 上,通常是防火墙或安全软件对没有放行规则的程序拦截了入站连接:请放行 Reasonix Studio 和它的 reasonix-studio-host 程序,然后重新启动。",
|
||||
],
|
||||
};
|
||||
|
||||
function startupFailure(locale, reason, logDir, code) {
|
||||
|
||||
@@ -718,7 +718,7 @@ test("a kernel that never answers is started once and not retried", { skip: proc
|
||||
test("each failure cause gets its own explanation in both languages", () => {
|
||||
const { startupFailure } = require("../src/shelllog.js");
|
||||
const seen = new Set();
|
||||
for (const code of ["timeout", "exited", "spawn"]) {
|
||||
for (const code of ["timeout", "exited", "spawn", "unreachable"]) {
|
||||
for (const locale of ["en", "zh-CN"]) {
|
||||
const { detail } = startupFailure(locale, "r", "/logs", code);
|
||||
assert.ok(!seen.has(detail), `${code}/${locale} repeated another cause's text`);
|
||||
@@ -728,6 +728,17 @@ test("each failure cause gets its own explanation in both languages", () => {
|
||||
assert.equal(startupFailure("en", "r", "/logs", "overflow").detail, "r\n\nLogs are in:\n/logs");
|
||||
});
|
||||
|
||||
test("the shell finds out at once that the host it started cannot be reached", async () => {
|
||||
const net = require("node:net");
|
||||
const { probeHost } = require("../src/hostprobe.js");
|
||||
const server = net.createServer((socket) => socket.end());
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const { port } = server.address();
|
||||
await probeHost(`http://127.0.0.1:${port}`, 2000);
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await assert.rejects(probeHost(`http://127.0.0.1:${port}`, 2000), (err) => err.code === "unreachable");
|
||||
});
|
||||
|
||||
// A fake child: only the stdout half readActs touches, and a way to push bytes
|
||||
// through it in whatever chunks the test wants -- which is the point, since the
|
||||
// bug this guards is a line split across two of them.
|
||||
@@ -3921,3 +3932,11 @@ test("a pick arms the page, answers what it reports, and a page that cannot answ
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the installer's by-name fallback for a process it cannot read the path of never names the shared computer-use driver", () => {
|
||||
const nsh = fs.readFileSync(new URL("../assets/installer.nsh", import.meta.url), "utf8");
|
||||
const listed = /\$\$names = @\(([^)]*)\)/.exec(nsh)?.[1] ?? "";
|
||||
assert.ok(listed.includes("${APP_EXECUTABLE_FILENAME}"), "the app's own executable is matched by name");
|
||||
assert.ok(listed.includes("reasonix-studio-host.exe"), "the shell's own host is matched by name");
|
||||
assert.ok(!listed.includes("cua-driver.exe"), "other computer-use apps ship the same driver name, so a path-less one is not ours");
|
||||
});
|
||||
@@ -36,6 +36,9 @@
|
||||
"@mantine/core": "9.7.4",
|
||||
"@mantine/hooks": "9.7.4",
|
||||
"@noble/curves": "2.4.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"docx-preview": "0.4.1",
|
||||
"dompurify": "^3.4.17",
|
||||
@@ -43,10 +46,10 @@
|
||||
"katex": "^0.19.1",
|
||||
"lucide-react": "^1.64.0",
|
||||
"maplibre-gl": "6.16.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"papaparse": "^5.7.0",
|
||||
"pdfjs-dist": "^6.4.299",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-map-gl": "8.1.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "3.10.1",
|
||||
|
||||
@@ -43,6 +43,7 @@ node perf/rail-toggle-panes.mjs # Rail-fold frame cost with 1/3/6 panes; existin
|
||||
node perf/verify.mjs # 行为验证:跟随、块的装卸、切页往返、大会话树
|
||||
node perf/panels.mjs # 两侧栏:收起补间、拖动改宽、上下限、窄屏退场
|
||||
node perf/look.mjs # 外观验证:字号、界面缩放、自定义字体、壁纸上传与调节(含焦点)
|
||||
node perf/versions-row.mjs # Version rows: download/rollback, notes, restart-ready and the 600px breakpoint in English and Chinese
|
||||
node perf/measure.mjs # Custom conversation percentage, preset retention, narrow composer controls and populated phone layout
|
||||
node perf/theme-schedule.mjs # Scheduled appearance: daily boundaries, midnight, reload/resume, modes, active packs and DST; THEME_SCHEDULE_SHOTS writes wide/phone light/dark screenshots
|
||||
node perf/pick.mjs # 补全菜单:/ 和 @ 的选中态,深浅两色下各走一遍
|
||||
@@ -76,6 +77,7 @@ node perf/focus.mjs # 专注的守卫:拿走外围之后,退出时世
|
||||
node perf/tooltip.mjs # 控件提示的守卫:文字不溢出提示框、不在半透明祖先之下、对比度达 AA(深浅×中英×宽窄)
|
||||
node perf/navhint.mjs # Icon-rail hover hints: drawn, inside the window, clipped by no ancestor, painted over by nothing (light/dark × on/collapsed)
|
||||
node perf/runningtool.mjs # 还没输出的运行中调用:行能展开、写明仍在运行与已运行多久、写出命令自带的超时
|
||||
node perf/tool-output-clip.mjs # Clipped results: no painted shadows; expand/collapse, horizontal scroll, full copy and reopen state (light/dark × wide/phone); TOOL_OUTPUT_SHOTS saves screenshots
|
||||
node perf/gutterhit.mjs # 分栏拖拽手柄不压在相邻滚动条上:滚动条拇指处按下命中的不是手柄,握把仍可拖开收起的栏
|
||||
node perf/dock.mjs # 停靠的浏览器列从 pane 顶部起、与导航行并排,取消停靠后行归还(浅/深 × 宽/窄 × 首开/重载)
|
||||
node perf/signals.mjs # 运行分析的信号轨说明:它是什么、为什么标记不能拖(浅/深 × 宽/手机)
|
||||
|
||||
@@ -19,11 +19,11 @@ for (const scheme of ["light", "dark"]) {
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(PAGE, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector('.projects-heading [data-action="workspace.add"]');
|
||||
await page.waitForSelector('.projects-header [data-action="workspace.add"]');
|
||||
const current = () => page.evaluate(() => [...document.querySelectorAll(".wsnode[data-current]")].map((el) => el.textContent ?? ""));
|
||||
const was = await current();
|
||||
if (SHOTS) await page.screenshot({ path: join(SHOTS, `${scheme}-before.png`) });
|
||||
await page.locator('.projects-heading [data-action="workspace.add"]').click();
|
||||
await page.locator('.projects-header [data-action="workspace.add"]').click();
|
||||
await page.getByRole("dialog", { name: "打开项目", exact: true }).getByRole("button", { name: "选择本机文件夹", exact: true }).click();
|
||||
await page.waitForFunction(() => [...document.querySelectorAll(".wsnode[data-current]")].some((el) => el.textContent?.includes("mock-workspace")), null, { timeout: 5000 }).catch(() => {});
|
||||
const now = await current();
|
||||
|
||||
@@ -16,11 +16,11 @@ try {
|
||||
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: theme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".projects-heading");
|
||||
await page.waitForSelector(".projects-header");
|
||||
await page.mouse.move(width - 2, 790);
|
||||
await page.waitForTimeout(300);
|
||||
const g = await page.evaluate(() => {
|
||||
const row = document.querySelector(".projects-heading");
|
||||
const row = document.querySelector(".projects-header");
|
||||
const add = row?.querySelector('[data-action="workspace.add"]');
|
||||
if (!add) return null;
|
||||
const r = add.getBoundingClientRect();
|
||||
|
||||
@@ -43,11 +43,11 @@ for (const scheme of ["light", "dark"]) {
|
||||
});
|
||||
await page.waitForSelector(".studio-deckchip");
|
||||
await page.locator('[data-action="deck.agents"]').hover();
|
||||
await page.locator('.studio-deck-pop .ag-open[data-target]').first().waitFor();
|
||||
await page.locator('.studio-deck-pop .ag[data-action="agent.open"][data-target]').first().waitFor();
|
||||
const rows = await page.locator(".studio-deck-pop .ag").count();
|
||||
check(`${scheme}: the deck lists both delegations`, rows === 2, `rows=${rows}`);
|
||||
|
||||
await page.locator('.studio-deck-pop .ag', { hasText: "security-review" }).locator('[data-action="agent.open"]').click();
|
||||
await page.locator('.studio-deck-pop .ag', { hasText: "security-review" }).click();
|
||||
await page.locator('.workbench-tab-pick', { hasText: "security-review" }).waitFor();
|
||||
await page.locator(".agentrun .txt", { hasText: "rotated on every refresh" }).waitFor();
|
||||
await page.waitForFunction(() => !document.querySelector(".agentrun")?.textContent.includes("## Finding"));
|
||||
@@ -68,7 +68,7 @@ for (const scheme of ["light", "dark"]) {
|
||||
check(`${scheme}: the record stays inside the workbench column`, box.flowRight <= box.wbRight + 1 && box.flowLeft >= box.wbLeft - 1, JSON.stringify(box));
|
||||
|
||||
await page.locator('[data-action="deck.agents"]').hover();
|
||||
await page.locator('.studio-deck-pop .ag', { hasText: "other" }).locator('[data-action="agent.open"]').click();
|
||||
await page.locator('.studio-deck-pop .ag', { hasText: "other" }).click();
|
||||
await page.locator(".agent-tx").waitFor();
|
||||
check(`${scheme}: a call with no run keeps the full-record dialog`, true);
|
||||
await page.close();
|
||||
|
||||
@@ -12,6 +12,8 @@ try {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (error) => fails.push(error.message));
|
||||
await page.goto(`${base}?pref=${language}&turns=2&git=none&zoom=${zoom}`, { waitUntil: "networkidle" });
|
||||
const more = page.locator(".compose .turn-more");
|
||||
if (await more.isVisible()) await more.click();
|
||||
const chip = page.locator(".studio-branch[data-norepo]");
|
||||
await chip.waitFor();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
|
||||
@@ -35,7 +35,7 @@ for (const scheme of ["light", "dark"]) {
|
||||
});
|
||||
const tag = `${scheme} ${width}px`;
|
||||
if (width >= 760) {
|
||||
check(`${tag}: the badge is on the wordmark's row, vertically centred on it`, m.shown && m.dy <= 1.5 && m.after, JSON.stringify(m));
|
||||
check(`${tag}: the badge is on the wordmark's row, within the 3px optical drop the sidebar board draws`, m.shown && m.dy <= 3.5 && m.after, JSON.stringify(m));
|
||||
check(`${tag}: it is an outlined uppercase badge in both themes and the text is untouched`, m.fill === "rgba(0, 0, 0, 0)" && m.border !== "0px" && m.radius !== "0px" && m.upper === "uppercase" && m.text === "studio", JSON.stringify(m));
|
||||
if (!seen) seen = { left: m.left, top: m.top };
|
||||
check(`${tag}: it sits in the same place whichever theme is on`, Math.abs(m.left - seen.left) <= 0.5 && Math.abs(m.top - seen.top) <= 0.5, `${m.left},${m.top} vs ${seen.left},${seen.top}`);
|
||||
|
||||
@@ -14,6 +14,7 @@ const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" });
|
||||
await page.addInitScript(() => localStorage.setItem("rx-nav-rail", "on"));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('.navbtn[aria-label="文件"]').first().click();
|
||||
|
||||
@@ -34,21 +34,23 @@ for (const scheme of ["light", "dark"]) {
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
await page.mouse.move(995, 5);
|
||||
const rows = await page.evaluate(() =>
|
||||
[...document.querySelectorAll(".flow .call")]
|
||||
const rows = await page.evaluate(() => {
|
||||
const read = (kind, shell, hl) => {
|
||||
const cs = getComputedStyle(shell);
|
||||
return {
|
||||
kind,
|
||||
radius: cs.borderTopLeftRadius,
|
||||
border: cs.borderTopColor,
|
||||
bg: cs.backgroundColor,
|
||||
head: hl ? getComputedStyle(hl).backgroundColor : "rgba(0, 0, 0, 0)",
|
||||
};
|
||||
};
|
||||
const calls = [...document.querySelectorAll(".flow .call")]
|
||||
.filter((c) => ["mem", "host", "extension"].includes(c.getAttribute("data-k")) || c.hasAttribute("data-prompt"))
|
||||
.map((c) => {
|
||||
const cs = getComputedStyle(c.querySelector(":scope > .c"));
|
||||
const hl = c.querySelector(":scope > .c > .hl");
|
||||
return {
|
||||
kind: c.getAttribute("data-k"),
|
||||
radius: cs.borderTopLeftRadius,
|
||||
border: cs.borderTopColor,
|
||||
bg: cs.backgroundColor,
|
||||
head: hl ? getComputedStyle(hl).backgroundColor : "rgba(0, 0, 0, 0)",
|
||||
};
|
||||
}));
|
||||
check(`${scheme}: four kinds of card found`, rows.length === 4, rows.map((r) => r.kind).join(","));
|
||||
.map((c) => read(c.getAttribute("data-k"), c.querySelector(":scope > .c"), c.querySelector(":scope > .c > .hl")));
|
||||
return calls;
|
||||
});
|
||||
check(`${scheme}: three kinds of card found`, rows.length === 3, rows.map((r) => r.kind).join(","));
|
||||
const same = (key) => new Set(rows.map((r) => r[key])).size === 1;
|
||||
check(`${scheme}: one radius`, rows.length > 0 && same("radius"), [...new Set(rows.map((r) => r.radius))].join(" / "));
|
||||
check(`${scheme}: one border colour, whatever the state`, rows.length > 0 && same("border"), [...new Set(rows.map((r) => r.border))].join(" / "));
|
||||
|
||||
@@ -18,6 +18,7 @@ try {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await ctx.addInitScript(() => localStorage.setItem("rx-nav-rail", "on"));
|
||||
await page.goto(`${BASE}?pref=zh&ws=2&sess=3&turns=2&changes=${CHANGES}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('.navbtn[aria-label="文件"]').first().click();
|
||||
@@ -26,7 +27,7 @@ try {
|
||||
const rows = await page.locator(".workbench-change-row").count();
|
||||
check(`${scheme}: ${CHANGES} changes leave the page under ${NODES_MAX} nodes`, nodes < NODES_MAX, `${nodes} nodes, ${rows} rows`);
|
||||
check(`${scheme}: the list says how many it left out`, (await page.locator(".workbench-change-more").textContent())?.includes("没有显示") === true);
|
||||
check(`${scheme}: the count in the head is every change`, (await page.locator(".workbench-changes .workbench-explorer-head small").first().textContent()) === String(CHANGES));
|
||||
check(`${scheme}: the count in the head is every change`, (await page.locator(".workbench-changes .workbench-explorer-head small").first().textContent())?.replace(/\D/g, "") === String(CHANGES));
|
||||
await page.locator(".workbench-change-more").scrollIntoViewIfNeeded();
|
||||
if (shotDir) await page.screenshot({ path: `${shotDir}/${scheme}-changes-cap.png` });
|
||||
await page.locator('[data-action="workbench.more-changes"]').click();
|
||||
|
||||
@@ -13,6 +13,7 @@ const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1400, height: 900 } });
|
||||
await page.addInitScript(() => localStorage.setItem("rx-nav-rail", "on"));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('.navbtn[aria-label="文件"]').first().click();
|
||||
|
||||
@@ -13,6 +13,7 @@ const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1400, height: 900 } });
|
||||
await page.addInitScript(() => localStorage.setItem("rx-nav-rail", "on"));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('.navbtn[aria-label="文件"]').first().click();
|
||||
|
||||
@@ -11,7 +11,7 @@ const check = (name, ok, detail = "") => {
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [pref, label] of [["zh", "询问来自外部的文字"], ["en", "Ask about text from outside"]]) {
|
||||
for (const [pref, label] of [["zh", "外部文字"], ["en", "Outside text"]]) {
|
||||
for (const width of [1280, 420]) {
|
||||
const tag = `${scheme} ${pref} ${width}px`;
|
||||
const page = await browser.newPage({ viewport: { width, height: 1000 }, locale: pref === "zh" ? "zh-CN" : "en-US", colorScheme: scheme, reducedMotion: "reduce" });
|
||||
@@ -19,13 +19,13 @@ for (const scheme of ["light", "dark"]) {
|
||||
await page.goto(`${BASE}?pref=${pref}&turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.waitForSelector(".prefs-nav button");
|
||||
await page.evaluate(() => { const b = [...document.querySelectorAll(".prefs-nav button")].find((x) => /工具与权限|Tools/.test(x.textContent)); b?.click(); });
|
||||
await page.waitForSelector('[data-action="settings.section"][data-value="tools"]');
|
||||
await page.evaluate(() => document.querySelector('[data-action="settings.section"][data-value="tools"]')?.click());
|
||||
const sw = page.getByRole("switch", { name: label });
|
||||
await sw.waitFor();
|
||||
check(`${tag}: it starts off`, (await sw.getAttribute("aria-checked")) === "false");
|
||||
await sw.scrollIntoViewIfNeeded();
|
||||
const said = await page.evaluate(() => document.body.textContent);
|
||||
const said = await page.evaluate(() => document.body.textContent + [...document.querySelectorAll("#set-external-origin [title]")].map((el) => el.title).join(" "));
|
||||
check(`${tag}: it says what it does not cover and that a headless run refuses`, pref === "zh" ? said.includes("改写过或编码过的文字不在其内") && said.includes("会被拒绝而不是执行") : said.includes("reworded or encoded") && said.includes("refused instead of run"));
|
||||
if (shotDir) { await page.mouse.move(width - 3, 995); await page.waitForTimeout(250); await page.locator("#set-external-origin").first().screenshot({ path: `${shotDir}/${scheme}-${pref}-${width}-off.png` }).catch(() => page.screenshot({ path: `${shotDir}/${scheme}-${pref}-${width}-off.png` })); }
|
||||
await sw.click();
|
||||
|
||||
@@ -39,6 +39,7 @@ const browser = await chromium.launch();
|
||||
for (const sc of SCENES) {
|
||||
const page = await browser.newPage({ viewport: { width: sc.w ?? 1400, height: 900 }, locale: "zh-CN", reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.addInitScript(() => localStorage.setItem("rx-nav-rail", "on"));
|
||||
await page.goto(BASE + sc.q, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
|
||||
@@ -19,6 +19,7 @@ try {
|
||||
const open = async (query) => {
|
||||
const page = await browser.newPage({ colorScheme: scheme, viewport: { width, height: 1000 }, locale: "zh-CN", reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.addInitScript(() => localStorage.setItem("rx-nav-rail", "on"));
|
||||
await page.goto(`${BASE}?pref=zh&ws=2&sess=3&turns=2${query}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('.navbtn[aria-label="文件"]').first().click();
|
||||
@@ -59,7 +60,7 @@ try {
|
||||
await one.waitForSelector('.push-card[data-phase="done"]');
|
||||
check(`${tag}: a confirmed push reports it`, (await one.locator(".push-card").textContent()).includes("已把 2 个提交推送到 github.com"));
|
||||
await shot(one, "pushed");
|
||||
await one.getByText("提交历史").click();
|
||||
if (!(await one.locator(".workbench-history").evaluate((d) => d.open))) await one.getByText("提交历史", { exact: true }).click();
|
||||
await one.getByRole("button", { name: /show the commit graph/ }).click();
|
||||
await one.getByRole("button", { name: /retry\.go/ }).last().click();
|
||||
await one.waitForSelector(".gh-diff");
|
||||
|
||||
@@ -1,54 +1,11 @@
|
||||
import "../src/styles/legacy.css";
|
||||
import "../src/design/design.css";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { boot as bootLang } from "../src/i18n";
|
||||
import { track as trackWidth } from "../src/ui/viewport";
|
||||
import { pushToast } from "../src/state/toasts";
|
||||
import { apply as applyPack } from "../src/ui/theme";
|
||||
import { mount as mountEditor } from "./syntax-mount";
|
||||
import "../src/styles/tokens.css";
|
||||
import "../src/styles/app.css";
|
||||
import "../src/styles/dock.css";
|
||||
import "../src/styles/studio.css";
|
||||
import "../src/styles/changedpill.css";
|
||||
import "../src/styles/askskip.css";
|
||||
import "../src/styles/pastekeep.css";
|
||||
import "../src/styles/ctxitems.css";
|
||||
import "../src/styles/sidepanel.css";
|
||||
import "../src/styles/contentmatches.css";
|
||||
import "../src/styles/scrolljump.css";
|
||||
import "../src/styles/chart.css";
|
||||
import "../src/styles/community.css";
|
||||
import "../src/styles/providerpresets.css";
|
||||
import "../src/styles/sessionnotes.css";
|
||||
import "../src/styles/sessionquick.css";
|
||||
import "../src/styles/sidebardrop.css";
|
||||
import "../src/styles/sponsor.css";
|
||||
import "../src/styles/versions.css";
|
||||
import "../src/styles/phonecomposer.css";
|
||||
import "../src/styles/stophint.css";
|
||||
import "../src/styles/dialogcenter.css";
|
||||
import "../src/styles/treerows.css";
|
||||
import "../src/styles/workbench-changes.css";
|
||||
import "../src/styles/agentrun.css";
|
||||
import "../src/styles/gitgraph.css";
|
||||
import "../src/styles/tips.css";
|
||||
import "../src/styles/waitnote.css";
|
||||
import "../src/styles/bring.css";
|
||||
import "../src/styles/peerthreads.css";
|
||||
import "../src/styles/line.css";
|
||||
import "../src/styles/notice.css";
|
||||
import "../src/styles/peers.css";
|
||||
import "../src/styles/jsontext.css";
|
||||
import "../src/styles/skillpin.css";
|
||||
import "../src/styles/narrow.css";
|
||||
import "../src/styles/workfold.css";
|
||||
import "../src/styles/workbench-launcher.css";
|
||||
import "../src/styles/feedback.css";
|
||||
import "../src/styles/toasts.css";
|
||||
import "../src/styles/modelmenu.css";
|
||||
import "../src/styles/composer-menu.css";
|
||||
import "../src/styles/runsignals.css";
|
||||
import "../src/styles/overview.css";
|
||||
import "../src/styles/themes.css";
|
||||
import { App } from "../src/ui/App";
|
||||
import { MockHub } from "../src/port/mock_hub";
|
||||
import { MockPort } from "../src/port/mock";
|
||||
@@ -56,7 +13,7 @@ import { fromHistory } from "../src/state/session";
|
||||
import { HttpError } from "../src/port/port";
|
||||
import type { AgentPort, Appearance, ChipCall, HistoryMessage, McpEntry } from "../src/port/port";
|
||||
import type { HistoryPage } from "../src/port/session";
|
||||
import type { UpdateProgress } from "../src/port/version";
|
||||
import type { UpdateProgress, VersionHub } from "../src/port/version";
|
||||
import type { ThemePack } from "../src/port/look";
|
||||
import type { RuntimeView, TreeWindow, TreeWorkspace } from "../src/port/hub";
|
||||
import type { WireEvent } from "../src/port/wire";
|
||||
@@ -218,8 +175,17 @@ class BenchPort extends MockPort {
|
||||
return query.has("update") ? "notify" : super.updateMode();
|
||||
}
|
||||
|
||||
async versions() {
|
||||
async versions(): Promise<VersionHub> {
|
||||
if (query.has("update")) return { current: "2.42.0", pinned: "", stalePin: false, latest: "2.43.0", newer: true, versions: [] };
|
||||
if (query.has("versionlayout")) {
|
||||
return {
|
||||
current: "2.56.0", pinned: "", stalePin: false, latest: "2.57.0", newer: true,
|
||||
versions: ["2.57.0", "2.56.1", "2.56.0", "2.55.0", "2.54.0", "2.53.1"].map((version, i) => ({
|
||||
version, tag: `studio-v${version}`, publishedAt: new Date(Date.now() - i * 86400000).toISOString(),
|
||||
hasNotes: i !== 4, current: i === 2, older: i > 2,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (!query.has("versions")) return super.versions();
|
||||
const names = ["2.38.0", "2.37.0", "2.36.0", "2.35.0", "2.34.0", "2.33.0", "2.32.0", "2.31.0", "2.30.0", "2.29.0"];
|
||||
return {
|
||||
@@ -307,12 +273,6 @@ class BenchPort extends MockPort {
|
||||
}
|
||||
|
||||
private settingCardSent = false;
|
||||
private receiptSent = false;
|
||||
|
||||
async receiptSettings() {
|
||||
const read = await super.receiptSettings();
|
||||
return query.has("receipt") ? { ...read, show: true } : read;
|
||||
}
|
||||
private meterSent = false;
|
||||
|
||||
subscribe(on: (e: WireEvent) => void) {
|
||||
@@ -328,14 +288,6 @@ class BenchPort extends MockPort {
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
if (query.has("receipt") && !this.receiptSent) {
|
||||
this.receiptSent = true;
|
||||
const script = "cd \"C:/Users/me/work\" && cat > \"$TMPDIR/check/verify_006.py\" <<'PYEOF'\n" + "import os, re, sys, unittest\nclass T(unittest.TestCase):\n def test_a(self):\n self.assertEqual(1, 1)\n".repeat(18) + "PYEOF\npython -m unittest verify_006 2>&1 | tail -12";
|
||||
setTimeout(() => {
|
||||
this.feed({ kind: "turn_started", msgIndex: 9 } as unknown as WireEvent);
|
||||
this.feed({ kind: "turn_done", receipt: { saysSomething: true, severity: "warn", gaps: [{ kind: "failed_verification", detail: script }, { kind: "inconclusive_verification", detail: "go test ./internal/... 2>&1 | tail -5" }] } } as unknown as WireEvent);
|
||||
}, 500);
|
||||
}
|
||||
const card = query.get("settingcard");
|
||||
if (card && !this.settingCardSent) {
|
||||
this.settingCardSent = true;
|
||||
@@ -447,10 +399,22 @@ class BenchPort extends MockPort {
|
||||
return super.switchBranch(name);
|
||||
}
|
||||
|
||||
async versionNotes(version: string) {
|
||||
if (!query.has("versionlayout")) return super.versionNotes(version);
|
||||
return { version, markdown: "# Release notes\n\nDownload and rollback improvements.", cached: false };
|
||||
}
|
||||
|
||||
async goToVersion() {}
|
||||
|
||||
onUpdateProgress(cb?: (p: UpdateProgress) => void) {
|
||||
if (cb && query.has("update")) queueMicrotask(() => cb({ version: "2.43.0", phase: "ready", received: 0, total: 0 }));
|
||||
const phase = query.get("versionlayout");
|
||||
if (cb && (phase === "downloading" || phase === "verifying" || phase === "ready")) {
|
||||
queueMicrotask(() => cb({
|
||||
version: query.has("rollback") ? "2.55.0" : "2.57.0", phase,
|
||||
received: 178_900_000, total: 183_900_000,
|
||||
}));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
@@ -524,10 +488,13 @@ class BenchHub extends MockHub {
|
||||
// ?search=1 answers every query with two conversations that say it.
|
||||
async searchHistory(q: string) {
|
||||
if (!query.has("search")) return super.searchHistory(q);
|
||||
return [
|
||||
{ path: "/sessions/mock.jsonl", id: "mock", snippet: `…上周讨论过要不要把 ${q} 的轮换放进发布流程,结论是每月一次…`, role: "assistant", messageIndex: 4, score: 2 },
|
||||
{ path: "/sessions/older.jsonl", id: "older", snippet: `怎么把 ${q} 配到预发环境里?`, role: "user", messageIndex: 0, score: 1 },
|
||||
];
|
||||
return {
|
||||
hits: [
|
||||
{ path: "/sessions/mock.jsonl", id: "mock", snippet: `…上周讨论过要不要把 ${q} 的轮换放进发布流程,结论是每月一次…`, role: "assistant", messageIndex: 4, score: 2 },
|
||||
{ path: "/sessions/older.jsonl", id: "older", snippet: `怎么把 ${q} 配到预发环境里?`, role: "user", messageIndex: 0, score: 1 },
|
||||
],
|
||||
index: { state: "ready", indexed: 2, total: 2, failed: 0, partial: false },
|
||||
};
|
||||
}
|
||||
|
||||
private readonly settingsPort = new BenchPort();
|
||||
@@ -672,7 +639,7 @@ function settingCardFrame(kind: string): WireEvent {
|
||||
return { kind: "notice", level: "info", code: "setting_open", text: "助手需要改动 permissions.mode,只有你本人能改。", detail: "permissions.mode" } as WireEvent;
|
||||
}
|
||||
const elevated = kind === "elevated";
|
||||
const id = elevated ? "peers.dispatch" : "ui.show_receipt";
|
||||
const id = elevated ? "peers.dispatch" : "ui.show_changed_files";
|
||||
return {
|
||||
kind: "approval_request",
|
||||
approval: {
|
||||
|
||||
@@ -38,6 +38,8 @@ try {
|
||||
check(`${context}: one hover ground across the row`, grounds.size === 1, [...grounds].join(" / "));
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(url.href, { waitUntil: "networkidle" });
|
||||
const more = page.locator(".compose .turn-more");
|
||||
if (await more.isVisible()) await more.click();
|
||||
const chip = page.locator(turns ? ".compose .studio-branch" : ".compose .worktree-trigger").first();
|
||||
await chip.click();
|
||||
check(`${context}: a narrow-pane pointer click opens the workspace menu`, await chip.getAttribute("aria-expanded") === "true");
|
||||
|
||||
@@ -6,9 +6,11 @@ import { chromium } from "playwright";
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
// Names the demo data itself carries, and names that are written in Chinese on purpose.
|
||||
const FIXTURE = [
|
||||
/并行会话演示/, /上一次的会话/, /站点改版/, /数据管线/, /跑一遍评测/, /^中文$/, /^做游戏的小鱼$/, /^DeepSeek-Reasonix官方群$/,
|
||||
/并行会话演示/, /上一次的会话/, /站点改版/, /数据管线/, /跑一遍评测/, /^中文$/, /^简体中文$/,/^做游戏的小鱼$/, /^DeepSeek-Reasonix官方群$/,
|
||||
/缓存命中率为什么会掉/, /Co-Authored-By/, /Generated with/, /^回复用中文$/, /^跟着用户每条消息的语言走$/, /v2 是从零重写的 Go 内核/,
|
||||
/没有 web,桌面端重做/, /问题里提到了 main-v2/, /构建用 -tags legacy/, /^迁移前的构建方式$/,
|
||||
/^发布流程$/, /^工作流$/, /^架构$/, /^用户偏好$/, /^发版前先确认合并队列$/, /^队列里有严重修复就先合进同一版$/, /^发版前先扫一遍待合并的修复/,
|
||||
/^每天最多发一个版本$/, /^热修只在完全不可用时例外$/, /^北京日历日最多发一个版本/, /^测试都在 Windows 机器上跑$/, /^Mac 只改代码$/, /^所有 Go\/前端测试和 lint/,
|
||||
];
|
||||
const fails = [];
|
||||
const browser = await chromium.launch();
|
||||
|
||||
@@ -18,12 +18,12 @@ for (const scheme of ["light", "dark"]) {
|
||||
await page.goto(`${BASE}?jobs=3&jobnames=1`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.waitForTimeout(800);
|
||||
await page.evaluate(() => [...document.querySelectorAll("button")].find((b) => /后台任务/.test(b.textContent))?.click());
|
||||
await page.waitForSelector(".jobs .job .cmd");
|
||||
await page.locator('[data-action="deck.jobs"]').click();
|
||||
await page.waitForSelector(".job-deck-name");
|
||||
const tag = `${scheme} ${width}px`;
|
||||
const shown = await page.evaluate(() => [...document.querySelectorAll(".jobs .job .cmd")].map((e) => e.textContent));
|
||||
check(`${tag}: the list shows the names, not the commands`, shown.length === 3 && shown.every((t) => !/go test|npm run/.test(t)), shown.join(" | "));
|
||||
await page.locator(".jobs .job .cmd").first().hover();
|
||||
const shown = await page.evaluate(() => [...document.querySelectorAll(".job-deck-name")].map((e) => e.textContent));
|
||||
check(`${tag}: the list shows the names, not the commands`, shown.length >= 3 && shown.every((t) => !/go test|npm run/.test(t)), shown.join(" | "));
|
||||
await page.locator(".job-deck-name").first().hover();
|
||||
await page.waitForSelector(".job-tip");
|
||||
const tip = await page.evaluate(() => {
|
||||
const el = document.querySelector(".job-tip");
|
||||
@@ -35,12 +35,12 @@ for (const scheme of ["light", "dark"]) {
|
||||
await page.mouse.move(width - 5, 5);
|
||||
await page.waitForTimeout(200);
|
||||
check(`${tag}: it closes when the pointer leaves`, (await page.locator(".job-tip").count()) === 0);
|
||||
await page.locator(".jobs .job .cmd").nth(1).focus();
|
||||
await page.locator(".job-deck-name").nth(1).focus();
|
||||
await page.waitForSelector(".job-tip");
|
||||
check(`${tag}: focus from the keyboard opens it too`, /内核测试/.test(await page.locator(".job-tip .jt-name").textContent()));
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(150);
|
||||
check(`${tag}: Escape closes it`, (await page.locator(".job-tip").count()) === 0);
|
||||
const closed = await page.locator(".job-tip").waitFor({ state: "detached", timeout: 2000 }).then(() => true, () => false);
|
||||
check(`${tag}: Escape closes it`, closed);
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// An allowed guardian review at low risk and a remembered fact are Lines, not cards: one row over a hairline with no box.
|
||||
// A refusal, or an allow that carried real risk, keeps the card.
|
||||
// An allowed guardian review at low risk is a Line, not a card: one row over a hairline with no box.
|
||||
// A refusal, or an allow that carried real risk, keeps its note, and a remembered fact is a note that carries its take-back.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
@@ -36,15 +36,15 @@ for (const scheme of ["light", "dark"]) {
|
||||
lines: rows.length,
|
||||
boxes: rows.map(box),
|
||||
radius: rows.map((r) => getComputedStyle(r).borderTopLeftRadius),
|
||||
cards: [...document.querySelectorAll('.flow .call[data-k="host"]')].length,
|
||||
forget: !!document.querySelector('.flow .line-row [data-action="memory.forget"]'),
|
||||
cards: [...document.querySelectorAll(".flow .note")].filter((n) => n.textContent.includes("守卫复核")).length,
|
||||
forget: !!document.querySelector('.flow .note [data-action="memory.forget"]'),
|
||||
oldMem: document.querySelectorAll('.flow .call[data-k="mem"]').length,
|
||||
};
|
||||
});
|
||||
check(`${scheme}: the remembered fact and the low-risk allow are rows`, m.lines === 2, `${m.lines} rows`);
|
||||
check(`${scheme}: the low-risk allow is a row`, m.lines === 1, `${m.lines} rows`);
|
||||
check(`${scheme}: rows carry no box`, m.lines > 0 && m.boxes.every((b) => b === "0px,0px,0px") && m.radius.every((r) => r === "0px"), JSON.stringify(m));
|
||||
check(`${scheme}: the high-risk allow and the refusal keep their cards`, m.cards === 2, `${m.cards} cards`);
|
||||
check(`${scheme}: the take-back sits in the row, and no memory card remains`, m.forget && m.oldMem === 0);
|
||||
check(`${scheme}: the high-risk allow and the refusal keep their notes`, m.cards === 2, `${m.cards} cards`);
|
||||
check(`${scheme}: the remembered fact keeps its take-back, and no memory card remains`, m.forget && m.oldMem === 0);
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
@@ -94,7 +94,8 @@ for (const scheme of ["light", "dark"]) {
|
||||
}
|
||||
|
||||
if (width === 1280) {
|
||||
await page.locator('[data-action="extensions.add"]').first().click();
|
||||
await page.locator('.ext-add-btn[data-action="extensions.add"]').first().click();
|
||||
await page.locator('[role="menuitem"][data-value="source"]').click();
|
||||
const source = page.locator("textarea, input").filter({ hasNot: page.locator("[aria-label]") }).last();
|
||||
await page.locator('textarea[placeholder*="github.com"], input[placeholder*="github.com"]').first().fill("https://github.com/acme/claude-code mod git-guard");
|
||||
await page.locator('[data-action="extensions.inspect"]').first().click();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1300, height: 900 } });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".model-picker");
|
||||
await page.locator(".model-picker").first().click();
|
||||
await page.waitForSelector(".studio-model-menu .studio-item-line");
|
||||
const sizes = await page.evaluate(() => {
|
||||
const lines = [...document.querySelectorAll(".studio-model-menu .studio-item-line")].filter((line) => line.querySelector(".studio-item-badge"));
|
||||
for (const line of lines) line.querySelector(".lb").textContent = "workbuddy/deepseek-v4.1-flash-preview-extended";
|
||||
return lines.map((line) => {
|
||||
const badge = line.querySelector(".studio-item-badge").getBoundingClientRect();
|
||||
return { width: Math.round(badge.width), height: Math.round(badge.height) };
|
||||
});
|
||||
});
|
||||
assert.ok(sizes.length > 0, "the menu has a model that reads images");
|
||||
for (const size of sizes) assert.ok(size.height <= 20, `a long model name leaves the badge on one line (${JSON.stringify(size)})`);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`PASS model menu badge stays on one line beside a long name (${sizes.length} rows)`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -18,7 +18,8 @@ try {
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 20000 });
|
||||
await page.getByRole("button", { name: "设置", exact: true }).click();
|
||||
await page.getByText("模型服务", { exact: true }).first().click();
|
||||
await page.locator('[role="tab"][title="模型"]').click();
|
||||
await page.locator('[data-action="settings.open-page"][data-target="providers"]').click();
|
||||
await page.getByText("myrelay", { exact: true }).first().click();
|
||||
await page.getByText("从服务商读取可用模型").first().click();
|
||||
await page.waitForSelector(".mrows .mline");
|
||||
|
||||
@@ -23,7 +23,8 @@ for (const scheme of ["light", "dark"]) {
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => /备份原文件并修复/.test(x.textContent))?.click());
|
||||
await page.waitForTimeout(400);
|
||||
await page.evaluate(() => [...document.querySelectorAll('[data-action="settings.section"]')].find((x) => x.textContent.includes("模型服务"))?.click());
|
||||
await page.evaluate(() => document.querySelector('[role="tab"][title="模型"]')?.click());
|
||||
await page.evaluate(() => document.querySelector('[data-action="settings.open-page"][data-target="providers"]')?.click());
|
||||
await page.waitForSelector(".pdetail", { state: "attached" });
|
||||
await page.evaluate(() => [...document.querySelectorAll("button,li,a")].find((x) => /myrelay-work/.test(x.textContent) && x.textContent.length < 80)?.click());
|
||||
await page.waitForSelector(".pdetail .mlhead", { state: "attached" });
|
||||
|
||||
@@ -139,6 +139,11 @@ for (const scheme of ["light", "dark"]) {
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.waitForTimeout(500);
|
||||
const more = page.locator(".compose .turn-more");
|
||||
if (await more.isVisible()) {
|
||||
await more.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
const tag = `${scheme} ${width}px zoom ${zoom}`;
|
||||
const m = await probe(page);
|
||||
check(`${tag}: title bar controls do not overlap`, m.bar.overlaps.length === 0, m.bar.overlaps.join(", "));
|
||||
|
||||
Loaded 100 of 2185 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user