agent-bios 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/claude/CLAUDE.md +8 -8
- package/claude/guides/cli-multi-model-workflow.md +6 -1
- package/claude/guides/coding-staged-workflow.md +12 -0
- package/claude/guides/learning-flow.md +4 -1
- package/claude/guides/llm-capability-boundary-patterns.md +8 -0
- package/claude/guides/review-request.md +13 -0
- package/claude/guides/session-distill-workflow.md +21 -9
- package/claude/guides/tooling-gotchas.md +181 -14
- package/claude/guides/verification-discipline.md +71 -3
- package/claude/hooks/tooling-gotchas-hook.py +50 -0
- package/codex/AGENTS.md +8 -8
- package/codex/guides/cli-multi-model-workflow.md +6 -1
- package/codex/guides/coding-staged-workflow.md +12 -0
- package/codex/guides/learning-flow.md +4 -1
- package/codex/guides/llm-capability-boundary-patterns.md +8 -0
- package/codex/guides/review-request.md +13 -0
- package/codex/guides/session-distill-workflow.md +21 -9
- package/codex/guides/tooling-gotchas.md +181 -14
- package/codex/guides/verification-discipline.md +71 -3
- package/install.sh +87 -5
- package/launch/agent-launch.zsh +109 -6
- package/learn/collect-learning.py +593 -62
- package/learn/redact.py +2 -1
- package/package.json +2 -2
- package/provenance.json +1 -1
|
@@ -27,8 +27,10 @@ Input: the semantic payload as one JSON object on stdin.
|
|
|
27
27
|
Host: --host claude|codex (default: the tool prefix of supporting_sessions[0]).
|
|
28
28
|
Home: --config-dir, else $CLAUDE_CONFIG_DIR / $CODEX_HOME by host, else ~/.claude / ~/.codex.
|
|
29
29
|
After the local writes it best-effort uploads not-yet-delivered records to
|
|
30
|
-
{
|
|
31
|
-
|
|
30
|
+
{base}/api/ingest/learnings via a watermark over learnings.jsonl. The base and
|
|
31
|
+
token resolve from the agent-bios-owned slot ~/.config/agent-bios/{ingest-url,
|
|
32
|
+
token} — the one transport contract, written by whoever adopts this install;
|
|
33
|
+
skipped when it is unset, or with --no-upload.
|
|
32
34
|
"""
|
|
33
35
|
import argparse
|
|
34
36
|
import datetime
|
|
@@ -38,8 +40,10 @@ import os
|
|
|
38
40
|
import pathlib
|
|
39
41
|
import shutil
|
|
40
42
|
import sys
|
|
43
|
+
import http.client
|
|
41
44
|
import time
|
|
42
45
|
import urllib.error
|
|
46
|
+
import urllib.parse
|
|
43
47
|
import urllib.request
|
|
44
48
|
import uuid
|
|
45
49
|
|
|
@@ -314,35 +318,139 @@ UPLOAD_LIMIT = 25 # max POSTs per invocation
|
|
|
314
318
|
UPLOAD_BUDGET_S = 5.0 # total wall-clock budget for the whole drain
|
|
315
319
|
UPLOAD_TIMEOUT_S = 3.0 # per-request socket timeout
|
|
316
320
|
PERMANENT_STATUSES = frozenset({400, 413}) # never succeeds → settle as dead
|
|
321
|
+
# A record or token urllib cannot turn into a request will never succeed either,
|
|
322
|
+
# so it settles like a 400 rather than stopping the drain: classed transient, one
|
|
323
|
+
# poison record wedged every later upload behind it and reported the wedge as a
|
|
324
|
+
# server outage (PR #44 regression review).
|
|
325
|
+
UNSENDABLE = "unsendable"
|
|
317
326
|
|
|
318
327
|
|
|
319
|
-
def
|
|
320
|
-
"""
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
hook_dir = home / "hooks"
|
|
324
|
-
token_file = hook_dir / "token"
|
|
325
|
-
if not token_file.is_file():
|
|
326
|
-
return None, "dashboard hook not installed (no token)"
|
|
328
|
+
def invalid_base(base):
|
|
329
|
+
"""None when `base` can carry a POST, else the reason. urllib turns a
|
|
330
|
+
scheme-less or host-less value into a ValueError at request construction,
|
|
331
|
+
which is a crash rather than a contract answer."""
|
|
327
332
|
try:
|
|
328
|
-
|
|
333
|
+
parts = urllib.parse.urlsplit(base)
|
|
334
|
+
except ValueError as e:
|
|
335
|
+
return f"unparseable ({e})"
|
|
336
|
+
if parts.scheme not in ("http", "https"):
|
|
337
|
+
return "no http(s) scheme"
|
|
338
|
+
try:
|
|
339
|
+
parts.port # raises for a port urllib will not accept
|
|
340
|
+
except ValueError as e:
|
|
341
|
+
return f"unusable port ({e})"
|
|
342
|
+
try:
|
|
343
|
+
parts.hostname.encode("idna") # a label urllib cannot encode at send
|
|
344
|
+
except (UnicodeError, AttributeError):
|
|
345
|
+
if parts.hostname:
|
|
346
|
+
return "host is not encodable"
|
|
347
|
+
if not parts.hostname:
|
|
348
|
+
# netloc alone is not enough: a value that is only a scheme and a port
|
|
349
|
+
# has a netloc and no host at all.
|
|
350
|
+
return "no host"
|
|
351
|
+
if "?" in base or "#" in base:
|
|
352
|
+
# The request path is APPENDED to this value as a string, so a base
|
|
353
|
+
# carrying a query or fragment produces `...?tenant=1/api/ingest/...` —
|
|
354
|
+
# a different selector than the contract names, which a server may
|
|
355
|
+
# answer 2xx to. The concatenation is the reason, so the refusal belongs
|
|
356
|
+
# here rather than at the send.
|
|
357
|
+
#
|
|
358
|
+
# Asked of the RAW value, not of `parts.query`/`parts.fragment`: urlsplit
|
|
359
|
+
# represents a bare `https://h.test?` as an empty string, which is falsy,
|
|
360
|
+
# so a truthiness test accepted exactly the delimiters that corrupt the
|
|
361
|
+
# selector. Present-but-empty is not absent.
|
|
362
|
+
return "carries a query or fragment"
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def transport_config(slot_root=None):
|
|
367
|
+
"""Resolve the upload transport (READ-ONLY; the slot is never written here).
|
|
368
|
+
|
|
369
|
+
ONE contract, no fallback: the agent-bios-owned slot
|
|
370
|
+
~/.config/agent-bios/{ingest-url,token}, one slot for both hosts. A slot
|
|
371
|
+
with either file present is claimed and must be complete and non-empty,
|
|
372
|
+
failing loud rather than degrading. Nothing about any organization is known
|
|
373
|
+
here — filling the slot is the adopter's side of the contract
|
|
374
|
+
(ENDPOINTS.md §Transport configuration), which is what lets this file ship
|
|
375
|
+
to anyone.
|
|
376
|
+
|
|
377
|
+
Returns ((token, base), None) or (None, reason); caller prints a notice."""
|
|
378
|
+
slot = (slot_root or pathlib.Path.home()) / ".config" / "agent-bios"
|
|
379
|
+
slot_token, slot_url = slot / "token", slot / "ingest-url"
|
|
380
|
+
# Two questions, deliberately separated. FIRST: can the slot be looked at at
|
|
381
|
+
# all? A path predicate answers False for both "absent" and "cannot look" (an
|
|
382
|
+
# EACCES at or above the slot), and reading the second as the first silently
|
|
383
|
+
# selected the legacy endpoint. Only ENOENT means absent; every other OSError
|
|
384
|
+
# is a claim we cannot read. The listing is CONSUMED because iterdir() may be
|
|
385
|
+
# a lazy generator — an unconsumed call can raise nothing at all.
|
|
386
|
+
# SECOND (below): is each file there? That is asked of the filesystem rather
|
|
387
|
+
# than of the listing's names, because a name-set test is case-sensitive and
|
|
388
|
+
# a case-insensitive filesystem (APFS) then stopped claiming a working slot
|
|
389
|
+
# holding TOKEN/INGEST-URL, resolving the legacy endpoint with no notice.
|
|
390
|
+
try:
|
|
391
|
+
list(slot.iterdir())
|
|
392
|
+
except FileNotFoundError:
|
|
393
|
+
pass
|
|
394
|
+
except NotADirectoryError:
|
|
395
|
+
return None, (f"transport slot {slot} is not a directory "
|
|
396
|
+
"(a claimed slot never falls back)")
|
|
329
397
|
except OSError as e:
|
|
330
|
-
return None, f"cannot read
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
#
|
|
335
|
-
#
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
398
|
+
return None, (f"transport slot {slot} cannot be read ({e}) — "
|
|
399
|
+
"a claimed slot never falls back")
|
|
400
|
+
# EXISTENCE and SHAPE are separate questions, and collapsing them is what
|
|
401
|
+
# this seam keeps getting wrong. `is_file()` answers False for a directory
|
|
402
|
+
# and for a dangling symlink as readily as for an absent path, so a slot
|
|
403
|
+
# holding either stopped being claimed and fell through to the legacy
|
|
404
|
+
# endpoint with no notice. `lstat` answers only "is something here",
|
|
405
|
+
# without following the link or judging its type; the shape is then a
|
|
406
|
+
# separate, loud requirement.
|
|
407
|
+
def present(path):
|
|
408
|
+
try:
|
|
409
|
+
os.lstat(path)
|
|
410
|
+
except FileNotFoundError:
|
|
411
|
+
return False
|
|
412
|
+
except OSError:
|
|
413
|
+
return True # something is there and we cannot look at it
|
|
414
|
+
return True
|
|
415
|
+
|
|
416
|
+
if present(slot_token) or present(slot_url):
|
|
417
|
+
for f, what in ((slot_token, "token"), (slot_url, "ingest-url")):
|
|
418
|
+
if not present(f):
|
|
419
|
+
return None, (f"transport slot {slot} is missing {what} "
|
|
420
|
+
"(a claimed slot never falls back)")
|
|
421
|
+
if not f.is_file():
|
|
422
|
+
return None, (f"transport slot {slot} has {what} but it is not a "
|
|
423
|
+
"readable file (a claimed slot never falls back)")
|
|
424
|
+
# One read per try, so the reason can name the file that actually
|
|
425
|
+
# failed — a shared try knows only that something did.
|
|
426
|
+
values = {}
|
|
427
|
+
for f, what in ((slot_token, "token"), (slot_url, "ingest-url")):
|
|
428
|
+
try:
|
|
429
|
+
values[what] = f.read_text(encoding="utf-8").strip()
|
|
430
|
+
except (OSError, ValueError) as e:
|
|
431
|
+
# ValueError covers UnicodeDecodeError: a slot file that is not
|
|
432
|
+
# UTF-8 is a broken claim, and raising here killed learn! the
|
|
433
|
+
# same way a bad URL did.
|
|
434
|
+
return None, f"cannot read transport slot file {f} ({e})"
|
|
435
|
+
token, base = values["token"], values["ingest-url"]
|
|
436
|
+
if not token:
|
|
437
|
+
return None, f"transport slot token is empty ({slot_token})"
|
|
438
|
+
if not token.isascii() or not token.isprintable():
|
|
439
|
+
# A token urllib will not put in a header is a config error, and
|
|
440
|
+
# discovering that at send time costs the whole drain.
|
|
441
|
+
return None, (f"transport slot token is not usable as a header value "
|
|
442
|
+
f"({slot_token})")
|
|
443
|
+
if not base:
|
|
444
|
+
return None, f"transport slot ingest-url is empty ({slot_url})"
|
|
445
|
+
bad = invalid_base(base)
|
|
446
|
+
if bad:
|
|
447
|
+
# Validated where the value is PRODUCED: a hand-typed url reaching
|
|
448
|
+
# http_post raised out of the drain and killed learn! after its
|
|
449
|
+
# local writes, and a transient skip would have hidden the typo.
|
|
450
|
+
return None, (f"transport slot ingest-url is not a usable endpoint "
|
|
451
|
+
f"({bad}: {base!r} in {slot_url})")
|
|
452
|
+
return (token, base.rstrip("/")), None
|
|
453
|
+
return None, f"no transport configured (no {slot} slot)"
|
|
346
454
|
|
|
347
455
|
|
|
348
456
|
def read_jsonl_records(jsonl):
|
|
@@ -391,6 +499,10 @@ def save_state(home, state, present_ids):
|
|
|
391
499
|
|
|
392
500
|
|
|
393
501
|
def classify_status(status):
|
|
502
|
+
if status == UNSENDABLE:
|
|
503
|
+
# Before any numeric comparison: this is not a status at all, and the
|
|
504
|
+
# range checks below raise TypeError on it.
|
|
505
|
+
return "permanent"
|
|
394
506
|
if status is None:
|
|
395
507
|
return "transient" # network error / timeout
|
|
396
508
|
if 200 <= status < 300:
|
|
@@ -400,29 +512,72 @@ def classify_status(status):
|
|
|
400
512
|
return "transient" # 401/403/404/429/503/5xx → retry next time
|
|
401
513
|
|
|
402
514
|
|
|
515
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
516
|
+
"""Refuse redirects: urllib re-sends a redirected POST as a bodyless GET, so
|
|
517
|
+
a 3xx (e.g. an http→https proxy hop) would settle the record while delivering
|
|
518
|
+
nothing. The 3xx surfaces as its own status instead → transient (fix the
|
|
519
|
+
configured URL; the record stays queued)."""
|
|
520
|
+
def redirect_request(self, *args, **kwargs):
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
_NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
525
|
+
|
|
526
|
+
|
|
403
527
|
def http_post(base, token, record, timeout=UPLOAD_TIMEOUT_S):
|
|
404
|
-
"""POST one record
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
528
|
+
"""POST one record. Returns the HTTP status int, None when the endpoint could
|
|
529
|
+
not be reached or used (transient — the record stays queued), or UNSENDABLE
|
|
530
|
+
when THIS RECORD can never be serialized (permanent — it settles).
|
|
531
|
+
|
|
532
|
+
Construction is inside the try with the send: a value urllib refuses to turn
|
|
533
|
+
into a request (a scheme-less base, a header it cannot encode) is an
|
|
534
|
+
unreachable endpoint like any other, and raising here killed learn! after
|
|
535
|
+
its local writes. transport_config rejects such a base up front, with the
|
|
536
|
+
file named — this is the belt behind that."""
|
|
537
|
+
try:
|
|
538
|
+
body = json.dumps(record, ensure_ascii=False).encode("utf-8")
|
|
539
|
+
except (ValueError, UnicodeError):
|
|
540
|
+
# THIS RECORD can never be sent — settle it so the queue keeps moving.
|
|
541
|
+
return UNSENDABLE
|
|
409
542
|
try:
|
|
410
|
-
|
|
543
|
+
req = urllib.request.Request(base + INGEST_PATH, data=body, method="POST")
|
|
544
|
+
req.add_header("Content-Type", "application/json")
|
|
545
|
+
req.add_header("X-Hook-Token", token)
|
|
546
|
+
with _NO_REDIRECT_OPENER.open(req, timeout=timeout) as resp:
|
|
411
547
|
return resp.status
|
|
412
548
|
except urllib.error.HTTPError as e:
|
|
413
549
|
return e.code
|
|
414
|
-
except (urllib.error.URLError, TimeoutError, OSError
|
|
550
|
+
except (urllib.error.URLError, TimeoutError, OSError,
|
|
551
|
+
http.client.HTTPException, ValueError, UnicodeError):
|
|
552
|
+
# A broken hop — a refused connection, a timeout, a gateway emitting a
|
|
553
|
+
# malformed status line — is the network, so the record stays pending.
|
|
554
|
+
#
|
|
555
|
+
# The same answer covers the endpoint and its configuration: a base
|
|
556
|
+
# urllib rejects (InvalidURL inherits both HTTPException and
|
|
557
|
+
# ValueError), a host label it cannot encode, a token it will not put in
|
|
558
|
+
# a header. Those are transient BY DESIGN — the records must survive so
|
|
559
|
+
# that fixing the configured value delivers them — and transport_config
|
|
560
|
+
# refuses such values up front anyway, with the file named. A second
|
|
561
|
+
# `return None` used to sit below this one to say so; it was
|
|
562
|
+
# unreachable, and a statement no execution can reach is a comment
|
|
563
|
+
# wearing code's clothes. Measured, not noticed.
|
|
415
564
|
return None
|
|
416
565
|
|
|
417
566
|
|
|
418
567
|
def drain_uploads(home, post_fn=http_post, now=time.monotonic,
|
|
419
|
-
limit=UPLOAD_LIMIT, budget_s=UPLOAD_BUDGET_S):
|
|
420
|
-
"""Best-effort upload of not-yet-settled learnings. post_fn/now are
|
|
421
|
-
for
|
|
422
|
-
|
|
568
|
+
limit=UPLOAD_LIMIT, budget_s=UPLOAD_BUDGET_S, slot_root=None):
|
|
569
|
+
"""Best-effort upload of not-yet-settled learnings. post_fn/now/slot_root are
|
|
570
|
+
injectable for tests (no sockets, no $HOME mutation). Returns a summary dict.
|
|
571
|
+
|
|
572
|
+
`status` separates two skips a single value used to blur: "unconfigured" is
|
|
573
|
+
the benign default install, "misconfigured" is a transport that WAS claimed
|
|
574
|
+
and is broken — the caller says so differently, because the second is the
|
|
575
|
+
one a user must act on."""
|
|
576
|
+
config, reason = transport_config(slot_root=slot_root)
|
|
423
577
|
if config is None:
|
|
424
|
-
|
|
425
|
-
|
|
578
|
+
claimed = reason is not None and not reason.startswith("no transport configured")
|
|
579
|
+
return {"status": "misconfigured" if claimed else "unconfigured",
|
|
580
|
+
"reason": reason, "uploaded": 0, "dead": 0, "pending": None}
|
|
426
581
|
token, base = config
|
|
427
582
|
|
|
428
583
|
records = read_jsonl_records(home / "personal" / "learnings.jsonl")
|
|
@@ -461,9 +616,16 @@ def drain_uploads(home, post_fn=http_post, now=time.monotonic,
|
|
|
461
616
|
|
|
462
617
|
|
|
463
618
|
def print_upload_summary(s):
|
|
464
|
-
if s["status"] == "
|
|
619
|
+
if s["status"] == "misconfigured":
|
|
620
|
+
# Not the benign skip: a transport was claimed and is broken, so the
|
|
621
|
+
# record will keep not uploading until someone fixes the file named.
|
|
622
|
+
print(f" upload -> NOT SENT: {s['reason']}")
|
|
623
|
+
print(" the record is captured and stays queued; fix the file "
|
|
624
|
+
"above, then run learn! again")
|
|
625
|
+
return
|
|
626
|
+
if s["status"] == "unconfigured":
|
|
465
627
|
print(f" upload -> skipped ({s['reason']}); "
|
|
466
|
-
"retries on a later learn! once
|
|
628
|
+
"retries on a later learn! once a transport is configured")
|
|
467
629
|
return
|
|
468
630
|
tail = f" (stopped: {s['reason']})" if s["reason"] else ""
|
|
469
631
|
print(f" upload -> uploaded={s['uploaded']} dead={s['dead']} "
|
|
@@ -477,18 +639,44 @@ def _self_test():
|
|
|
477
639
|
non-zero on any failure."""
|
|
478
640
|
import tempfile
|
|
479
641
|
|
|
480
|
-
|
|
481
|
-
|
|
642
|
+
# One scratch tree for the whole run, removed at the end: every mkdtemp here
|
|
643
|
+
# used to survive the process, and the gate that calls this ran on every
|
|
644
|
+
# commit — 10k directories before anyone counted.
|
|
645
|
+
scratch = pathlib.Path(tempfile.mkdtemp(prefix="learn-selftest-"))
|
|
646
|
+
made = {"n": 0}
|
|
647
|
+
|
|
648
|
+
def scratch_dir(tag):
|
|
649
|
+
made["n"] += 1
|
|
650
|
+
d = scratch / f"{tag}-{made['n']}"
|
|
651
|
+
d.mkdir(parents=True)
|
|
652
|
+
return d
|
|
653
|
+
|
|
654
|
+
# The slot root is passed EXPLICITLY rather than steered through $HOME: a
|
|
655
|
+
# test that has to mutate the environment to stay off the operator's live
|
|
656
|
+
# endpoint is patching the caller instead of the seam.
|
|
657
|
+
empty_root = scratch_dir("empty-root")
|
|
658
|
+
|
|
659
|
+
def make_slot(name, token="tok", url="https://example.test/"):
|
|
660
|
+
"""A slot the core accepts — the ADOPTER's side of the contract, planted
|
|
661
|
+
explicitly. Transport no longer follows from the home a record is
|
|
662
|
+
written to: the home holds the records, the slot root holds the
|
|
663
|
+
endpoint, and the tests keep them separate because the code does."""
|
|
664
|
+
root = scratch_dir(name)
|
|
665
|
+
slot = root / ".config" / "agent-bios"
|
|
666
|
+
slot.mkdir(parents=True)
|
|
667
|
+
(slot / "token").write_text(token + "\n", encoding="utf-8")
|
|
668
|
+
(slot / "ingest-url").write_text(url + "\n", encoding="utf-8")
|
|
669
|
+
return root
|
|
670
|
+
|
|
671
|
+
live_root = make_slot("live-root")
|
|
672
|
+
|
|
673
|
+
def make_home(records):
|
|
674
|
+
home = scratch_dir("home")
|
|
482
675
|
(home / "personal").mkdir(parents=True)
|
|
483
676
|
with open(home / "personal" / "learnings.jsonl", "w", encoding="utf-8") as f:
|
|
484
677
|
for r in records:
|
|
485
678
|
f.write(r if isinstance(r, str) else json.dumps(r))
|
|
486
679
|
f.write("\n")
|
|
487
|
-
if with_token:
|
|
488
|
-
(home / "hooks").mkdir(parents=True)
|
|
489
|
-
(home / "hooks" / "token").write_text("tok", encoding="utf-8")
|
|
490
|
-
(home / "hooks" / "dashboard-url").write_text(
|
|
491
|
-
"https://example.test/", encoding="utf-8")
|
|
492
680
|
return home
|
|
493
681
|
|
|
494
682
|
def rec(n):
|
|
@@ -500,19 +688,19 @@ def _self_test():
|
|
|
500
688
|
checks = []
|
|
501
689
|
|
|
502
690
|
# 1) no token → skipped, no state file written (watermark unadvanced).
|
|
503
|
-
h = make_home([rec(1)]
|
|
504
|
-
s = drain_uploads(h)
|
|
505
|
-
checks.append(("no-token skip", s["status"] == "
|
|
691
|
+
h = make_home([rec(1)])
|
|
692
|
+
s = drain_uploads(h, slot_root=empty_root)
|
|
693
|
+
checks.append(("no-token skip", s["status"] == "unconfigured"
|
|
506
694
|
and not (h / "personal" / STATE_NAME).is_file()))
|
|
507
695
|
|
|
508
696
|
# 2) all ok → all uploaded, pending 0; re-run uploads nothing new.
|
|
509
697
|
h = make_home([rec(1), rec(2), rec(3)])
|
|
510
|
-
s = drain_uploads(h, post_fn=lambda *a: 200)
|
|
698
|
+
s = drain_uploads(h, slot_root=live_root, post_fn=lambda *a: 200)
|
|
511
699
|
calls = {"n": 0}
|
|
512
700
|
def count_post(*a):
|
|
513
701
|
calls["n"] += 1
|
|
514
702
|
return 200
|
|
515
|
-
s2 = drain_uploads(h, post_fn=count_post)
|
|
703
|
+
s2 = drain_uploads(h, slot_root=live_root, post_fn=count_post)
|
|
516
704
|
checks.append(("all-ok then idempotent re-run",
|
|
517
705
|
s["uploaded"] == 3 and s["pending"] == 0
|
|
518
706
|
and calls["n"] == 0 and s2["uploaded"] == 0))
|
|
@@ -520,8 +708,8 @@ def _self_test():
|
|
|
520
708
|
# 3) transient (500) → nothing settled, drain stops, pending == N; a later
|
|
521
709
|
# 200 run delivers everything (multi-day-outage recovery).
|
|
522
710
|
h = make_home([rec(1), rec(2)])
|
|
523
|
-
s = drain_uploads(h, post_fn=lambda *a: 500)
|
|
524
|
-
s2 = drain_uploads(h, post_fn=lambda *a: 200)
|
|
711
|
+
s = drain_uploads(h, slot_root=live_root, post_fn=lambda *a: 500)
|
|
712
|
+
s2 = drain_uploads(h, slot_root=live_root, post_fn=lambda *a: 200)
|
|
525
713
|
checks.append(("transient stop then recover",
|
|
526
714
|
s["uploaded"] == 0 and s["pending"] == 2
|
|
527
715
|
and s["reason"] == "transient"
|
|
@@ -529,24 +717,24 @@ def _self_test():
|
|
|
529
717
|
|
|
530
718
|
# 4) permanent (400) → settled as dead, not retried.
|
|
531
719
|
h = make_home([rec(1)])
|
|
532
|
-
s = drain_uploads(h, post_fn=lambda *a: 400)
|
|
720
|
+
s = drain_uploads(h, slot_root=live_root, post_fn=lambda *a: 400)
|
|
533
721
|
hits = {"n": 0}
|
|
534
722
|
def once(*a):
|
|
535
723
|
hits["n"] += 1
|
|
536
724
|
return 400
|
|
537
|
-
s2 = drain_uploads(h, post_fn=once)
|
|
725
|
+
s2 = drain_uploads(h, slot_root=live_root, post_fn=once)
|
|
538
726
|
checks.append(("permanent drop, not retried",
|
|
539
727
|
s["dead"] == 1 and s["pending"] == 0 and hits["n"] == 0))
|
|
540
728
|
|
|
541
729
|
# 5) network error (None) is transient.
|
|
542
730
|
h = make_home([rec(1)])
|
|
543
|
-
s = drain_uploads(h, post_fn=lambda *a: None)
|
|
731
|
+
s = drain_uploads(h, slot_root=live_root, post_fn=lambda *a: None)
|
|
544
732
|
checks.append(("network error is transient",
|
|
545
733
|
s["uploaded"] == 0 and s["pending"] == 1))
|
|
546
734
|
|
|
547
735
|
# 6) poison line skipped, valid records still delivered.
|
|
548
736
|
h = make_home(["{ not json", rec(1), ""])
|
|
549
|
-
s = drain_uploads(h, post_fn=lambda *a: 200)
|
|
737
|
+
s = drain_uploads(h, slot_root=live_root, post_fn=lambda *a: 200)
|
|
550
738
|
checks.append(("poison line skipped", s["uploaded"] == 1 and s["pending"] == 0))
|
|
551
739
|
|
|
552
740
|
# 7) wall-clock budget stops the drain (fake clock jumps past the deadline).
|
|
@@ -555,8 +743,31 @@ def _self_test():
|
|
|
555
743
|
clock["t"] += 10.0
|
|
556
744
|
return clock["t"]
|
|
557
745
|
h = make_home([rec(1), rec(2)])
|
|
558
|
-
|
|
559
|
-
|
|
746
|
+
budget_sent = {"n": 0}
|
|
747
|
+
def budget_post(*a):
|
|
748
|
+
budget_sent["n"] += 1
|
|
749
|
+
return 200
|
|
750
|
+
s = drain_uploads(h, slot_root=live_root, post_fn=budget_post, now=fake_now, budget_s=5.0)
|
|
751
|
+
# ZERO sends, not just the reason: the deadline is checked BEFORE a send,
|
|
752
|
+
# and a reordering that posts first still reports reason "budget".
|
|
753
|
+
checks.append(("budget stop", s["reason"] == "budget" and budget_sent["n"] == 0))
|
|
754
|
+
|
|
755
|
+
# 7b) the BOUNDARY, not just the far side: a clock still inside the budget
|
|
756
|
+
# must send. A deadline test widened to "will probably overrun" passes
|
|
757
|
+
# the check above while suppressing a send that was still allowed.
|
|
758
|
+
inside = {"t": 0.0}
|
|
759
|
+
def near_now():
|
|
760
|
+
inside["t"] += 0.1 # 0.1, 0.2, ... — never reaches a 5.0s budget
|
|
761
|
+
return inside["t"]
|
|
762
|
+
h = make_home([rec(1)])
|
|
763
|
+
sent_inside = {"n": 0}
|
|
764
|
+
def inside_post(*a):
|
|
765
|
+
sent_inside["n"] += 1
|
|
766
|
+
return 200
|
|
767
|
+
s = drain_uploads(h, slot_root=live_root, post_fn=inside_post,
|
|
768
|
+
now=near_now, budget_s=5.0)
|
|
769
|
+
checks.append(("a send still inside the budget is not suppressed",
|
|
770
|
+
sent_inside["n"] == 1 and s["uploaded"] == 1))
|
|
560
771
|
|
|
561
772
|
# 8) capture-time secret redaction is wired into build_record, so secrets in
|
|
562
773
|
# free text never reach the durable log / upload / curator export.
|
|
@@ -598,6 +809,326 @@ def _self_test():
|
|
|
598
809
|
import_line_index(f"# e\n```\n{CLAUDE_CENTRAL_IMPORT}\n```\n",
|
|
599
810
|
CLAUDE_CENTRAL_IMPORT) is None))
|
|
600
811
|
|
|
812
|
+
# 11) transport resolution: the slot is the whole contract, and the
|
|
813
|
+
# instrument is held to the exact planted values — a bare non-None
|
|
814
|
+
# result would pass on a value nobody planted.
|
|
815
|
+
own_root = scratch_dir("own-root")
|
|
816
|
+
slot = own_root / ".config" / "agent-bios"
|
|
817
|
+
slot.mkdir(parents=True)
|
|
818
|
+
(slot / "token").write_text("own-tok\n", encoding="utf-8")
|
|
819
|
+
(slot / "ingest-url").write_text("https://own.test/\n", encoding="utf-8")
|
|
820
|
+
cfg, why = transport_config(slot_root=own_root)
|
|
821
|
+
checks.append(("the slot resolves to exactly what was planted",
|
|
822
|
+
cfg == ("own-tok", "https://own.test") and why is None))
|
|
823
|
+
|
|
824
|
+
# 12) a claimed slot missing a half fails loud — BOTH directions, since
|
|
825
|
+
# either half alone claims the slot.
|
|
826
|
+
(slot / "ingest-url").unlink()
|
|
827
|
+
cfg, why = transport_config(slot_root=own_root)
|
|
828
|
+
checks.append(("claimed slot never falls back (url missing)",
|
|
829
|
+
cfg is None and "missing ingest-url" in (why or "")))
|
|
830
|
+
(slot / "ingest-url").write_text("https://own.test/\n", encoding="utf-8")
|
|
831
|
+
(slot / "token").unlink()
|
|
832
|
+
cfg, why = transport_config(slot_root=own_root)
|
|
833
|
+
checks.append(("claimed slot never falls back (token missing)",
|
|
834
|
+
cfg is None and "missing token" in (why or "")))
|
|
835
|
+
(slot / "token").write_text("own-tok\n", encoding="utf-8")
|
|
836
|
+
|
|
837
|
+
# 12c) an unreadable slot is loud, never a silent legacy fallback — and the
|
|
838
|
+
# EACCES is planted one level ABOVE the slot, which is where a path
|
|
839
|
+
# predicate answers "absent" instead of "cannot look". Skipped for
|
|
840
|
+
# uid 0, which ignores the permission bits and would make this control
|
|
841
|
+
# quietly test nothing (or fail for the wrong reason).
|
|
842
|
+
if os.geteuid() != 0:
|
|
843
|
+
os.chmod(own_root / ".config", 0o600)
|
|
844
|
+
try:
|
|
845
|
+
cfg, why = transport_config(slot_root=own_root)
|
|
846
|
+
finally:
|
|
847
|
+
os.chmod(own_root / ".config", 0o700)
|
|
848
|
+
checks.append(("unreadable slot is loud (EACCES above it)",
|
|
849
|
+
cfg is None and "cannot be read" in (why or "")))
|
|
850
|
+
|
|
851
|
+
# 12c-2) the two remaining shapes of "something is there and we cannot read
|
|
852
|
+
# it", both found by measuring which statements the self-test never
|
|
853
|
+
# executed rather than by imagining them. This seam has leaked six
|
|
854
|
+
# times on exactly this question, and these branches existed for it
|
|
855
|
+
# while nothing exercised them.
|
|
856
|
+
notdir_root = scratch_dir("notdir-root")
|
|
857
|
+
(notdir_root / ".config").mkdir(parents=True)
|
|
858
|
+
(notdir_root / ".config" / "agent-bios").write_text("not a directory\n",
|
|
859
|
+
encoding="utf-8")
|
|
860
|
+
cfg, why = transport_config(slot_root=notdir_root)
|
|
861
|
+
checks.append(("a file where the slot directory belongs is loud",
|
|
862
|
+
cfg is None and "is not a directory" in (why or "")))
|
|
863
|
+
|
|
864
|
+
if os.geteuid() != 0:
|
|
865
|
+
# Readable but not searchable (r without x): the listing succeeds and
|
|
866
|
+
# naming the entries works, while lstat on them raises EACCES. A slot
|
|
867
|
+
# that answers "cannot look" per FILE rather than per directory.
|
|
868
|
+
nox_root = scratch_dir("nox-root")
|
|
869
|
+
nox_slot = nox_root / ".config" / "agent-bios"
|
|
870
|
+
nox_slot.mkdir(parents=True)
|
|
871
|
+
(nox_slot / "token").write_text("t\n", encoding="utf-8")
|
|
872
|
+
(nox_slot / "ingest-url").write_text("https://x.test/\n", encoding="utf-8")
|
|
873
|
+
os.chmod(nox_slot, 0o400)
|
|
874
|
+
try:
|
|
875
|
+
cfg, why = transport_config(slot_root=nox_root)
|
|
876
|
+
finally:
|
|
877
|
+
os.chmod(nox_slot, 0o700)
|
|
878
|
+
checks.append(("a slot whose entries cannot be stat'ed is loud, not absent",
|
|
879
|
+
cfg is None and "not a readable file" in (why or "")))
|
|
880
|
+
|
|
881
|
+
# 13) an empty slot value is its own loud failure, not a skip or fallback —
|
|
882
|
+
# each half through its own door (a shared door would let one branch's
|
|
883
|
+
# rejection vanish while the other keeps the check green).
|
|
884
|
+
(slot / "ingest-url").write_text("\n", encoding="utf-8")
|
|
885
|
+
cfg, why = transport_config(slot_root=own_root)
|
|
886
|
+
checks.append(("empty slot url is loud",
|
|
887
|
+
cfg is None and "transport slot ingest-url is empty" in (why or "")))
|
|
888
|
+
(slot / "ingest-url").write_text("https://own.test/\n", encoding="utf-8")
|
|
889
|
+
(slot / "token").write_text("\n", encoding="utf-8")
|
|
890
|
+
cfg, why = transport_config(slot_root=own_root)
|
|
891
|
+
checks.append(("empty slot token is loud",
|
|
892
|
+
cfg is None and "transport slot token is empty" in (why or "")))
|
|
893
|
+
(slot / "token").write_text("own-tok\n", encoding="utf-8")
|
|
894
|
+
|
|
895
|
+
# 13b) a base urllib cannot turn into a request is rejected where the value
|
|
896
|
+
# is PRODUCED, naming the file — reaching http_post with it raised out
|
|
897
|
+
# of the drain and killed learn! after the local writes.
|
|
898
|
+
# The host-less case is spelled in two pieces so this fixture is not itself
|
|
899
|
+
# a hardcoded URL in a shipped file (gates/check-endpoints.py urls leg).
|
|
900
|
+
for bad, needle in (("dashboard.example.com", "no http(s) scheme"),
|
|
901
|
+
("https:" "//", "no host"),
|
|
902
|
+
("ftp://x.example", "no http(s) scheme"),
|
|
903
|
+
# The path is appended by concatenation, so these two
|
|
904
|
+
# produce a selector the contract never names.
|
|
905
|
+
("https://own.test/?tenant=1", "query or fragment"),
|
|
906
|
+
("https://own.test/#x", "query or fragment"),
|
|
907
|
+
# The EMPTY delimiters, which urlsplit reports as ""
|
|
908
|
+
# and a truthiness test therefore accepted.
|
|
909
|
+
("https://own.test/?", "query or fragment"),
|
|
910
|
+
("https://own.test/#", "query or fragment"),
|
|
911
|
+
# A value urlsplit itself refuses. The reason must come
|
|
912
|
+
# back as a reason, not as a raise out of the drain.
|
|
913
|
+
("http:" "//[::1", "unparseable")):
|
|
914
|
+
(slot / "ingest-url").write_text(bad + "\n", encoding="utf-8")
|
|
915
|
+
cfg, why = transport_config(slot_root=own_root)
|
|
916
|
+
checks.append((f"unusable slot url is loud ({bad!r})",
|
|
917
|
+
cfg is None and needle in (why or "")))
|
|
918
|
+
(slot / "ingest-url").write_text("https://own.test/\n", encoding="utf-8")
|
|
919
|
+
|
|
920
|
+
# 13c) and a misconfigured transport is reported through its OWN channel:
|
|
921
|
+
# the benign "nothing configured" default and a broken claimed slot
|
|
922
|
+
# are the two cases a single "skipped" status used to blur.
|
|
923
|
+
(slot / "ingest-url").write_text("not-a-url\n", encoding="utf-8")
|
|
924
|
+
h = make_home([rec(1)])
|
|
925
|
+
s = drain_uploads(h, slot_root=own_root, post_fn=lambda *a: 200)
|
|
926
|
+
checks.append(("broken slot reports misconfigured, not skipped",
|
|
927
|
+
s["status"] == "misconfigured" and s["uploaded"] == 0))
|
|
928
|
+
(slot / "ingest-url").write_text("https://own.test/\n", encoding="utf-8")
|
|
929
|
+
|
|
930
|
+
# 12d) a slot entry that EXISTS but is not a readable file is claimed and
|
|
931
|
+
# loud, never a silent legacy fallback: a dangling symlink and a
|
|
932
|
+
# directory both answer False to is_file(), exactly as an absent path
|
|
933
|
+
# does, and that is the third shape this seam has leaked through.
|
|
934
|
+
shape_root = scratch_dir("shape-root")
|
|
935
|
+
shape_slot = shape_root / ".config" / "agent-bios"
|
|
936
|
+
shape_slot.mkdir(parents=True)
|
|
937
|
+
(shape_slot / "token").symlink_to(shape_root / "nothing-here")
|
|
938
|
+
(shape_slot / "ingest-url").symlink_to(shape_root / "nothing-here-either")
|
|
939
|
+
cfg, why = transport_config(slot_root=shape_root)
|
|
940
|
+
checks.append(("dangling slot symlinks are loud, not a silent fallback",
|
|
941
|
+
cfg is None and "not a readable file" in (why or "")))
|
|
942
|
+
(shape_slot / "token").unlink()
|
|
943
|
+
(shape_slot / "token").mkdir()
|
|
944
|
+
cfg, why = transport_config(slot_root=shape_root)
|
|
945
|
+
checks.append(("a directory where a slot file belongs is loud",
|
|
946
|
+
cfg is None and "not a readable file" in (why or "")))
|
|
947
|
+
|
|
948
|
+
# 12d-2) the reason names the FILE that is unreadable, and a non-UTF-8 slot
|
|
949
|
+
# file is a misconfiguration rather than a crash — the whole point of
|
|
950
|
+
# catching ValueError beside OSError on every config read.
|
|
951
|
+
utf_root = scratch_dir("utf-root")
|
|
952
|
+
utf_slot = utf_root / ".config" / "agent-bios"
|
|
953
|
+
utf_slot.mkdir(parents=True)
|
|
954
|
+
(utf_slot / "token").write_bytes(b"\xff\xfe not utf-8")
|
|
955
|
+
(utf_slot / "ingest-url").write_text("https://own.test/\n", encoding="utf-8")
|
|
956
|
+
|
|
957
|
+
def resolved(root):
|
|
958
|
+
"""A RAISE is the failure this pair exists to catch, so it becomes a
|
|
959
|
+
reason string rather than killing the self-test alongside its subject."""
|
|
960
|
+
try:
|
|
961
|
+
return transport_config(slot_root=root)
|
|
962
|
+
except Exception as e: # noqa: BLE001
|
|
963
|
+
return None, f"RAISED {type(e).__name__}: {e}"
|
|
964
|
+
|
|
965
|
+
cfg, why = resolved(utf_root)
|
|
966
|
+
checks.append(("a non-UTF-8 slot file is loud and names itself",
|
|
967
|
+
cfg is None and str(utf_slot / "token") in (why or "")))
|
|
968
|
+
|
|
969
|
+
# 12e-2) a token urllib cannot put in a header is refused at the source; a
|
|
970
|
+
# config-caused send failure must stay TRANSIENT so the records
|
|
971
|
+
# survive the fix, while a record-caused one settles.
|
|
972
|
+
(utf_slot / "token").write_text("tok en\twith control\n", encoding="utf-8")
|
|
973
|
+
cfg, why = transport_config(slot_root=utf_root)
|
|
974
|
+
checks.append(("an unusable token is refused at the source",
|
|
975
|
+
cfg is None and "header value" in (why or "")))
|
|
976
|
+
checks.append(("a config-caused send failure stays transient (records survive)",
|
|
977
|
+
classify_status(http_post("https:" "//" + "a" * 64 + ".test", "t",
|
|
978
|
+
rec(1), timeout=0.2)) == "transient"))
|
|
979
|
+
checks.append(("and a host urllib cannot encode is refused at the source",
|
|
980
|
+
invalid_base("https:" "//" + "a" * 64 + ".test") is not None))
|
|
981
|
+
|
|
982
|
+
# 12f) the hostname requirement, on a base that HAS a netloc: reverting to a
|
|
983
|
+
# netloc test passes every other fixture, since the host-less one is a
|
|
984
|
+
# bare scheme with an empty netloc.
|
|
985
|
+
# Spelled in pieces so this fixture is not itself a hardcoded URL in a
|
|
986
|
+
# shipped file (the urls leg), while still being a netloc with no host.
|
|
987
|
+
checks.append(("a netloc without a host is refused",
|
|
988
|
+
invalid_base("https:" "//" ":443") is not None))
|
|
989
|
+
|
|
990
|
+
# 12e) a base whose port urllib rejects is refused where the value is
|
|
991
|
+
# produced; if one reaches http_post anyway it settles rather than
|
|
992
|
+
# wedging, because InvalidURL inherits the network exception too.
|
|
993
|
+
checks.append(("an unusable port is refused at the source",
|
|
994
|
+
invalid_base("http://host.test:abc") is not None))
|
|
995
|
+
checks.append(("and an unparseable base stays transient at send (config, "
|
|
996
|
+
"not a dead record)",
|
|
997
|
+
classify_status(http_post("http://host.test:abc", "t",
|
|
998
|
+
rec(1), timeout=0.2)) == "transient"))
|
|
999
|
+
|
|
1000
|
+
# 13d) a record or token that can never be SENT settles like a 400 instead
|
|
1001
|
+
# of stopping the drain: classed transient, one poison record wedged
|
|
1002
|
+
# every later upload behind it and reported the wedge as an outage.
|
|
1003
|
+
poison = json.loads('{"learning_id": "0f8c1c2a-4d1e-4abc-9def-000000000099",'
|
|
1004
|
+
' "schema_version": 1, "domain": "core",'
|
|
1005
|
+
' "created": "2026-08-20T00:00:00Z",'
|
|
1006
|
+
' "supporting_sessions": ["claude:abcd1234"],'
|
|
1007
|
+
' "lesson": "\\ud800"}')
|
|
1008
|
+
checks.append(("unsendable record settles permanent, never transient",
|
|
1009
|
+
classify_status(http_post("https://x.test", "tok", poison,
|
|
1010
|
+
timeout=0.2)) == "permanent"))
|
|
1011
|
+
h = make_home([poison, rec(1)])
|
|
1012
|
+
# By VALUE, not identity: the drain re-parses each record out of the JSONL,
|
|
1013
|
+
# so `is` compares against an object the drain never sees.
|
|
1014
|
+
s = drain_uploads(h, slot_root=own_root,
|
|
1015
|
+
post_fn=lambda base, tok, r: (http_post(base, tok, r, 0.2)
|
|
1016
|
+
if r.get("lesson") != "x" * 12
|
|
1017
|
+
else 200))
|
|
1018
|
+
checks.append(("a poison record does not wedge the queue behind it",
|
|
1019
|
+
s["dead"] == 1 and s["uploaded"] == 1 and s["pending"] == 0))
|
|
1020
|
+
|
|
1021
|
+
# 13d-2) the classifier is checked EXHAUSTIVELY, not on sampled statuses.
|
|
1022
|
+
# A sampled matrix tests the codes someone thought of: 202 was in no
|
|
1023
|
+
# sample and no static literal, so a carve-out excluding it from 2xx
|
|
1024
|
+
# would have made every accepted-but-async delivery retry forever
|
|
1025
|
+
# while every test stayed green. The rule is derived from the same
|
|
1026
|
+
# constants the contract publishes, so this compares behaviour with
|
|
1027
|
+
# declaration rather than with a second copy of itself.
|
|
1028
|
+
wrong = []
|
|
1029
|
+
for status in range(100, 600):
|
|
1030
|
+
want = ("ok" if 200 <= status < 300
|
|
1031
|
+
else "permanent" if status in PERMANENT_STATUSES
|
|
1032
|
+
else "transient")
|
|
1033
|
+
if classify_status(status) != want:
|
|
1034
|
+
wrong.append((status, classify_status(status), want))
|
|
1035
|
+
checks.append((f"classify_status agrees with the declared sets on every "
|
|
1036
|
+
f"status 100-599 (first divergences: {wrong[:3]})", not wrong))
|
|
1037
|
+
# and the two values that are not statuses at all keep their own answers
|
|
1038
|
+
checks.append(("a network error is transient and UNSENDABLE is permanent",
|
|
1039
|
+
classify_status(None) == "transient"
|
|
1040
|
+
and classify_status(UNSENDABLE) == "permanent"))
|
|
1041
|
+
|
|
1042
|
+
# 13e) presence is asked of the FILESYSTEM, not of a listing's names: on a
|
|
1043
|
+
# case-insensitive filesystem a slot holding TOKEN/INGEST-URL is the
|
|
1044
|
+
# same slot, and a name-set test silently reported it unconfigured.
|
|
1045
|
+
ci_root = scratch_dir("ci-root")
|
|
1046
|
+
ci_slot = ci_root / ".config" / "agent-bios"
|
|
1047
|
+
ci_slot.mkdir(parents=True)
|
|
1048
|
+
(ci_slot / "TOKEN").write_text("ci-tok\n", encoding="utf-8")
|
|
1049
|
+
(ci_slot / "INGEST-URL").write_text("https://ci.test/\n", encoding="utf-8")
|
|
1050
|
+
cfg, why = transport_config(slot_root=ci_root)
|
|
1051
|
+
if (ci_slot / "token").is_file(): # only meaningful where the FS folds case
|
|
1052
|
+
checks.append(("case-folded slot is still claimed",
|
|
1053
|
+
cfg == ("ci-tok", "https://ci.test") and why is None))
|
|
1054
|
+
else:
|
|
1055
|
+
checks.append(("case-folded slot check skipped (case-sensitive filesystem)",
|
|
1056
|
+
cfg is None or cfg[1] != "https://ci.test"))
|
|
1057
|
+
|
|
1058
|
+
# 13f) the promise is that a broken slot names THE FILE, and that the two
|
|
1059
|
+
# skips read differently to a user — the message is the whole product of
|
|
1060
|
+
# a loud failure, so it is asserted rather than assumed.
|
|
1061
|
+
import io, contextlib
|
|
1062
|
+
def summary_text(s):
|
|
1063
|
+
buf = io.StringIO()
|
|
1064
|
+
with contextlib.redirect_stdout(buf):
|
|
1065
|
+
print_upload_summary(s)
|
|
1066
|
+
return buf.getvalue()
|
|
1067
|
+
|
|
1068
|
+
(slot / "ingest-url").write_text("not-a-url\n", encoding="utf-8")
|
|
1069
|
+
cfg, why = transport_config(slot_root=own_root)
|
|
1070
|
+
broken = summary_text({"status": "misconfigured", "reason": why,
|
|
1071
|
+
"uploaded": 0, "dead": 0, "pending": None})
|
|
1072
|
+
benign = summary_text({"status": "unconfigured", "reason": "no transport configured",
|
|
1073
|
+
"uploaded": 0, "dead": 0, "pending": None})
|
|
1074
|
+
checks.append(("a broken slot's reason names the file",
|
|
1075
|
+
str(slot / "ingest-url") in (why or "")))
|
|
1076
|
+
checks.append(("the two skips read differently to a user",
|
|
1077
|
+
"NOT SENT" in broken and "NOT SENT" not in benign
|
|
1078
|
+
and broken != benign))
|
|
1079
|
+
(slot / "ingest-url").write_text("https://own.test/\n", encoding="utf-8")
|
|
1080
|
+
|
|
1081
|
+
# 14) no slot → the skip reason names the slot it looked for, and names it
|
|
1082
|
+
# as an absolute path: under a redirected HOME a tilde would be a lie
|
|
1083
|
+
# about where the adopter must write.
|
|
1084
|
+
cfg, why = transport_config(slot_root=empty_root)
|
|
1085
|
+
checks.append(("default resolves no transport",
|
|
1086
|
+
cfg is None and "no transport configured" in (why or "")
|
|
1087
|
+
and str(empty_root / ".config" / "agent-bios") in (why or "")))
|
|
1088
|
+
|
|
1089
|
+
# 15) the drain posts to the slot's base — the resolved value reaches the
|
|
1090
|
+
# wire, rather than merely being returned by the resolver.
|
|
1091
|
+
h = make_home([rec(1)])
|
|
1092
|
+
seen = []
|
|
1093
|
+
def spy(base, token, record):
|
|
1094
|
+
seen.append((base, token))
|
|
1095
|
+
return 200
|
|
1096
|
+
s = drain_uploads(h, slot_root=own_root, post_fn=spy)
|
|
1097
|
+
checks.append(("drain posts to the slot base",
|
|
1098
|
+
s["uploaded"] == 1 and seen == [("https://own.test", "own-tok")]))
|
|
1099
|
+
|
|
1100
|
+
# 16) the organization provider is gone from the core, measured through the
|
|
1101
|
+
# DRAIN rather than the resolver: a home carrying the old dashboard hook
|
|
1102
|
+
# files, with no slot, must degrade to unconfigured and send nothing. A
|
|
1103
|
+
# re-added fallback would resolve that home and upload — which is the
|
|
1104
|
+
# failure this asserts against, and why the check is made where a send
|
|
1105
|
+
# could actually happen.
|
|
1106
|
+
orphan = make_home([rec(1)])
|
|
1107
|
+
(orphan / "hooks").mkdir(parents=True)
|
|
1108
|
+
(orphan / "hooks" / "token").write_text("tok", encoding="utf-8")
|
|
1109
|
+
(orphan / "hooks" / "dashboard-url").write_text("https://example.test/",
|
|
1110
|
+
encoding="utf-8")
|
|
1111
|
+
sent = {"n": 0}
|
|
1112
|
+
def orphan_post(*a):
|
|
1113
|
+
sent["n"] += 1
|
|
1114
|
+
return 200
|
|
1115
|
+
s = drain_uploads(orphan, slot_root=empty_root, post_fn=orphan_post)
|
|
1116
|
+
checks.append(("a host-home hook dir is not a transport",
|
|
1117
|
+
s["status"] == "unconfigured" and sent["n"] == 0))
|
|
1118
|
+
|
|
1119
|
+
# 17) the POST limit is a hard cap, measured: limit=1 over two records sends
|
|
1120
|
+
# exactly one and stops with reason "limit" — an off-by-one flip in the
|
|
1121
|
+
# comparison sends a 26th POST that no anchor can see.
|
|
1122
|
+
h = make_home([rec(1), rec(2)])
|
|
1123
|
+
capped = {"n": 0}
|
|
1124
|
+
def cap(*a):
|
|
1125
|
+
capped["n"] += 1
|
|
1126
|
+
return 200
|
|
1127
|
+
s = drain_uploads(h, slot_root=live_root, post_fn=cap, limit=1)
|
|
1128
|
+
checks.append(("post limit is a hard cap",
|
|
1129
|
+
capped["n"] == 1 and s["uploaded"] == 1 and s["reason"] == "limit"))
|
|
1130
|
+
|
|
1131
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
601
1132
|
failed = [name for name, ok in checks if not ok]
|
|
602
1133
|
if failed:
|
|
603
1134
|
for name in failed:
|