sbxloop 0.7.2__tar.gz → 0.7.4__tar.gz
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.
- {sbxloop-0.7.2 → sbxloop-0.7.4}/PKG-INFO +1 -1
- sbxloop-0.7.2/src/sbxloop/_vendor/sbxloop_worker-0.7.2-py3-none-any.whl → sbxloop-0.7.4/src/sbxloop/_vendor/sbxloop_worker-0.7.4-py3-none-any.whl +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/_version.py +2 -2
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/cli/app.py +18 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/config.py +29 -0
- sbxloop-0.7.4/src/sbxloop/daemon/audits.py +150 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/backlog.py +79 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/discord_format.py +4 -1
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/loop.py +158 -8
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/model.py +11 -0
- sbxloop-0.7.4/src/sbxloop/daemon/postmortem.py +157 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/sources.py +150 -32
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/store.py +114 -2
- {sbxloop-0.7.2 → sbxloop-0.7.4}/.gitignore +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/README.md +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/hatch_build.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/pyproject.toml +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/__init__.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/cli/__init__.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/cli/doctor.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/cli/models.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/cli/tui.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/__init__.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/control.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/discord.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/github.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/daemon/paths.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/deliver.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/__init__.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/engine.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/model.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/phases.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/prompts/__init__.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/prompts/decompose.md +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/prompts/execute.md +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/prompts/plan.md +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/prompts/steer.md +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/prompts/validate.md +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/engine/store.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/errors.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/events.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/gc.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/gh/__init__.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/gh/ops.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/gh/reporter.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/hostgit.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/ids.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/policy.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/py.typed +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/__init__.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/bake.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/cli.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/conformance.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/models.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/pair.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/parse.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/provision.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/prune.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/sandbox.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/sbx/secretstate.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/toolchains.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/verifylint.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/worker/__init__.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/worker/client.py +0 -0
- {sbxloop-0.7.2 → sbxloop-0.7.4}/src/sbxloop/worker/wheel.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sbxloop
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.4
|
|
4
4
|
Summary: Agentic loop orchestration on Docker Sandboxes (sbx) with isolated credential domains
|
|
5
5
|
Project-URL: Homepage, https://github.com/brettbergin/sbxloop
|
|
6
6
|
Project-URL: Repository, https://github.com/brettbergin/sbxloop
|
|
Binary file
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.7.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 7,
|
|
21
|
+
__version__ = version = '0.7.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 7, 4)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -1477,6 +1477,14 @@ def daemon(
|
|
|
1477
1477
|
if config.daemon.backlog == "github" and not github_active:
|
|
1478
1478
|
console.print("[bold red]--backlog github needs a GitHub repository[/] (--repo).")
|
|
1479
1479
|
raise typer.Exit(2)
|
|
1480
|
+
if github_active and config.daemon.backlog != "github":
|
|
1481
|
+
# The audit lane is always on when issues are a source; its
|
|
1482
|
+
# findings only have somewhere to go with backlog = "github".
|
|
1483
|
+
console.print(
|
|
1484
|
+
f"[yellow]note:[/] audit issues (`{config.daemon.audit_label}`) file their "
|
|
1485
|
+
"findings as backlog issues, which needs [cyan]--backlog github[/]; with "
|
|
1486
|
+
f"backlog={config.daemon.backlog!r} an audit run completes but files nothing."
|
|
1487
|
+
)
|
|
1480
1488
|
|
|
1481
1489
|
# Say where the state went: with the anchored default, `sbxloop status`
|
|
1482
1490
|
# in the runner dir shows nothing unless SBXLOOP_STATE_DIR points here.
|
|
@@ -1500,6 +1508,7 @@ def daemon(
|
|
|
1500
1508
|
config.daemon.failed_label,
|
|
1501
1509
|
config.daemon.backlog_label,
|
|
1502
1510
|
config.daemon.delivered_label,
|
|
1511
|
+
config.daemon.audit_label,
|
|
1503
1512
|
)
|
|
1504
1513
|
sources.append(
|
|
1505
1514
|
GitHubIssueSource(
|
|
@@ -1508,6 +1517,7 @@ def daemon(
|
|
|
1508
1517
|
labels,
|
|
1509
1518
|
on_failure=github.note_failure,
|
|
1510
1519
|
close_on_success=config.daemon.close_on_success,
|
|
1520
|
+
tool_repo=config.daemon.tool_repo,
|
|
1511
1521
|
)
|
|
1512
1522
|
)
|
|
1513
1523
|
|
|
@@ -1998,6 +2008,14 @@ mem_abort = 0.0
|
|
|
1998
2008
|
# failed_label = "sbxloop:failed"
|
|
1999
2009
|
# backlog_label = "sbxloop:backlog"
|
|
2000
2010
|
# delivered_label = "sbxloop:delivered"
|
|
2011
|
+
# audit_label = "sbxloop:audit" # discovery lane: investigate, file findings, no PR
|
|
2012
|
+
# postmortems = true # file a post-mortem audit when a patch item fails
|
|
2013
|
+
# postmortems_per_day = 3
|
|
2014
|
+
# audits = false # open .github/sbxloop/audits/*.md charters on schedule
|
|
2015
|
+
# review_deliveries = true # audit each PR the loop delivers (defects → backlog)
|
|
2016
|
+
# reviews_per_day = 5
|
|
2017
|
+
# tool_repo = "brettbergin/sbxloop" # findings ABOUT sbxloop go here, never to the project
|
|
2018
|
+
# audit_dir = ".github/sbxloop/audits"
|
|
2001
2019
|
# What a delivered run does to the tracker. Defaults are task-queue
|
|
2002
2020
|
# semantics: close the source issue (the PR is the reviewable object) and
|
|
2003
2021
|
# open a per-run tracking issue. For a design/discussion tracker set both
|
|
@@ -361,6 +361,30 @@ class DaemonConfig(_ConfigModel):
|
|
|
361
361
|
failed_label: str = "sbxloop:failed"
|
|
362
362
|
backlog_label: str = "sbxloop:backlog"
|
|
363
363
|
delivered_label: str = "sbxloop:delivered"
|
|
364
|
+
# Discovery lane: an issue carrying this label is a charter — the run
|
|
365
|
+
# investigates and files findings as backlog issues, never a PR.
|
|
366
|
+
audit_label: str = "sbxloop:audit"
|
|
367
|
+
# When a patch item is abandoned (or delivers nothing), file a post-mortem
|
|
368
|
+
# charter carrying the plan, verify transcripts and failure events, so the
|
|
369
|
+
# discovery lane turns the daemon's own failures into evidenced findings.
|
|
370
|
+
# Never for audit items (no recursion); capped per rolling day.
|
|
371
|
+
postmortems: bool = True
|
|
372
|
+
postmortems_per_day: int = 3
|
|
373
|
+
# Scheduled area audits: charters versioned in the target repo under
|
|
374
|
+
# ``audit_dir`` (front-matter `every: 7d`), opened as audit issues when
|
|
375
|
+
# due. Off by default — a repo opts in by carrying charters AND the
|
|
376
|
+
# operator flipping this on.
|
|
377
|
+
audits: bool = False
|
|
378
|
+
audit_dir: Path = Path(".github/sbxloop/audits")
|
|
379
|
+
# After a run delivers a PR, open a review audit of that PR — the loop
|
|
380
|
+
# evaluating the code it just wrote (defects, missing edge cases, scope
|
|
381
|
+
# drift) and filing findings for a human to promote.
|
|
382
|
+
review_deliveries: bool = True
|
|
383
|
+
reviews_per_day: int = 5
|
|
384
|
+
# Where findings ABOUT THE TOOL (sbxloop's planner, prompts, lint,
|
|
385
|
+
# delivery) go — the tool's own tracker, never the project's. Unset:
|
|
386
|
+
# such findings are only noted in the closing comment.
|
|
387
|
+
tool_repo: str | None = None
|
|
364
388
|
close_on_success: bool = True
|
|
365
389
|
tracking_issue: bool = True
|
|
366
390
|
max_runs_per_day: int = 12
|
|
@@ -394,6 +418,7 @@ class DaemonConfig(_ConfigModel):
|
|
|
394
418
|
self.failed_label,
|
|
395
419
|
self.backlog_label,
|
|
396
420
|
self.delivered_label,
|
|
421
|
+
self.audit_label,
|
|
397
422
|
]
|
|
398
423
|
if any(not label.strip() for label in labels):
|
|
399
424
|
raise ValueError("daemon labels must be non-empty")
|
|
@@ -406,9 +431,13 @@ class DaemonConfig(_ConfigModel):
|
|
|
406
431
|
"max_attempts_per_item",
|
|
407
432
|
"max_consecutive_failures",
|
|
408
433
|
"backlog_max_per_run",
|
|
434
|
+
"postmortems_per_day",
|
|
435
|
+
"reviews_per_day",
|
|
409
436
|
):
|
|
410
437
|
if getattr(self, name) < 1:
|
|
411
438
|
raise ValueError(f"daemon.{name} must be >= 1")
|
|
439
|
+
if self.tool_repo is not None and not re.fullmatch(r"[\w.-]+/[\w.-]+", self.tool_repo):
|
|
440
|
+
raise ValueError(f"daemon.tool_repo must be owner/name, got {self.tool_repo!r}")
|
|
412
441
|
return self
|
|
413
442
|
|
|
414
443
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Scheduled area audits: charters versioned in the target repository.
|
|
2
|
+
|
|
3
|
+
The discovery lane needs a steady supply of *good* charters, and Brett
|
|
4
|
+
wants every moving part visible on GitHub. So the charters live in the
|
|
5
|
+
repo itself — ``.github/sbxloop/audits/<name>.md`` with a small front-matter block —
|
|
6
|
+
where they are reviewed like code, and the daemon opens each one as a
|
|
7
|
+
``sbxloop:audit`` issue when it is due. What is due is decided from the
|
|
8
|
+
issues that already exist (title search on GitHub, plus the store as a
|
|
9
|
+
cache), so a wiped state dir cannot double-file and an audit that is still
|
|
10
|
+
open is never re-opened on top of itself.
|
|
11
|
+
|
|
12
|
+
Pure parsing/scheduling here; the loop does the filing.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
AUDIT_DIR = Path(".github") / "sbxloop" / "audits"
|
|
22
|
+
AUDIT_TITLE_PREFIX = "audit: "
|
|
23
|
+
_FRONT_MATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.S)
|
|
24
|
+
# Preferred metadata form: an HTML comment. Invisible on GitHub and left
|
|
25
|
+
# alone by every Markdown formatter (mdformat rewrote a `---` YAML block
|
|
26
|
+
# into a thematic break + an H2 the first time the charters shipped, and
|
|
27
|
+
# the daemon then saw no metadata at all).
|
|
28
|
+
_COMMENT_META = re.compile(r"\A\s*<!--\s*sbxloop(?:-audit)?\s*:?\s*(.*?)\s*-->\s*\n?", re.S)
|
|
29
|
+
_EVERY = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([dhm])\s*$", re.I)
|
|
30
|
+
_UNIT_S = {"d": 86_400.0, "h": 3_600.0, "m": 60.0}
|
|
31
|
+
_NAME_OK = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Charter:
|
|
36
|
+
name: str
|
|
37
|
+
every_s: float
|
|
38
|
+
enabled: bool
|
|
39
|
+
body: str
|
|
40
|
+
rel: str # path relative to the checkout, for the issue's provenance line
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def title(self) -> str:
|
|
44
|
+
return f"{AUDIT_TITLE_PREFIX}{self.name}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_every(text: str) -> float:
|
|
48
|
+
"""``7d`` / ``12h`` / ``30m`` → seconds. ``0d`` means every tick (tests,
|
|
49
|
+
or "run once now")."""
|
|
50
|
+
m = _EVERY.match(text)
|
|
51
|
+
if not m:
|
|
52
|
+
raise ValueError(f"unrecognised interval {text!r} (use e.g. 7d, 12h, 30m)")
|
|
53
|
+
return float(m.group(1)) * _UNIT_S[m.group(2).lower()]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_charter(path: Path, rel: str | None = None) -> Charter:
|
|
57
|
+
"""One charter file. Metadata keys: ``every`` (required), ``enabled``
|
|
58
|
+
(default true), given as ``<!-- sbxloop: every=7d -->`` on the first line
|
|
59
|
+
(preferred) or a ``---`` front-matter block. The name is the file stem,
|
|
60
|
+
which also keys the schedule and the issue title — keep it stable."""
|
|
61
|
+
name = path.stem
|
|
62
|
+
if not _NAME_OK.match(name):
|
|
63
|
+
raise ValueError(f"charter file name {name!r} must be lowercase [a-z0-9._-]")
|
|
64
|
+
text = path.read_text(encoding="utf-8")
|
|
65
|
+
meta: dict[str, str] = {}
|
|
66
|
+
m = _COMMENT_META.match(text)
|
|
67
|
+
if m:
|
|
68
|
+
# `<!-- sbxloop: every=7d enabled=false -->` (also `key: value`)
|
|
69
|
+
for token in re.split(r"[\s,;]+", m.group(1).strip()):
|
|
70
|
+
if not token:
|
|
71
|
+
continue
|
|
72
|
+
key, sep, value = token.partition("=") if "=" in token else token.partition(":")
|
|
73
|
+
if sep:
|
|
74
|
+
meta[key.strip().lower()] = value.strip().strip("'\"")
|
|
75
|
+
else:
|
|
76
|
+
m = _FRONT_MATTER.match(text)
|
|
77
|
+
if not m:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"{path}: missing metadata — start the file with "
|
|
80
|
+
"`<!-- sbxloop: every=7d -->` (or a --- every: 7d --- front-matter block)"
|
|
81
|
+
)
|
|
82
|
+
for line in m.group(1).splitlines():
|
|
83
|
+
if ":" in line:
|
|
84
|
+
key, _, value = line.partition(":")
|
|
85
|
+
meta[key.strip().lower()] = value.strip().strip("'\"")
|
|
86
|
+
if "every" not in meta:
|
|
87
|
+
raise ValueError(f"{path}: front-matter needs `every: <interval>`")
|
|
88
|
+
body = text[m.end() :].strip()
|
|
89
|
+
if not body:
|
|
90
|
+
raise ValueError(f"{path}: charter body is empty")
|
|
91
|
+
enabled = meta.get("enabled", "true").lower() not in ("false", "no", "0", "off")
|
|
92
|
+
return Charter(
|
|
93
|
+
name=name,
|
|
94
|
+
every_s=parse_every(meta["every"]),
|
|
95
|
+
enabled=enabled,
|
|
96
|
+
body=body,
|
|
97
|
+
rel=rel or path.name,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_charters(checkout: Path, audit_dir: Path = AUDIT_DIR) -> tuple[list[Charter], list[str]]:
|
|
102
|
+
"""(charters, problems) under ``checkout/audit_dir``; a bad file is a
|
|
103
|
+
problem string, never a crash — one broken charter must not stop the
|
|
104
|
+
others."""
|
|
105
|
+
folder = checkout / audit_dir
|
|
106
|
+
if not folder.is_dir():
|
|
107
|
+
return [], []
|
|
108
|
+
charters: list[Charter] = []
|
|
109
|
+
problems: list[str] = []
|
|
110
|
+
for path in sorted(folder.glob("*.md")):
|
|
111
|
+
try:
|
|
112
|
+
charters.append(parse_charter(path, path.relative_to(checkout).as_posix()))
|
|
113
|
+
except (ValueError, OSError) as exc:
|
|
114
|
+
problems.append(str(exc))
|
|
115
|
+
return charters, problems
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def due_charters(
|
|
119
|
+
charters: list[Charter], last_filed: dict[str, float], now: float
|
|
120
|
+
) -> list[Charter]:
|
|
121
|
+
"""Enabled charters whose interval has elapsed since they were last
|
|
122
|
+
filed (never filed → due)."""
|
|
123
|
+
out = []
|
|
124
|
+
for c in charters:
|
|
125
|
+
if not c.enabled:
|
|
126
|
+
continue
|
|
127
|
+
last = last_filed.get(c.name)
|
|
128
|
+
if last is None or now - last >= c.every_s:
|
|
129
|
+
out.append(c)
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def issue_body(charter: Charter, marker: str) -> str:
|
|
134
|
+
return (
|
|
135
|
+
f"{charter.body}\n\n---\n"
|
|
136
|
+
f"Scheduled audit `{charter.name}` (every {describe_every(charter.every_s)}); "
|
|
137
|
+
f"charter: `{charter.rel}`.\n"
|
|
138
|
+
f"{marker}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def audit_marker(name: str) -> str:
|
|
143
|
+
return f"<!-- sbxloop-audit {name} -->"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def describe_every(seconds: float) -> str:
|
|
147
|
+
for unit, size in (("d", 86_400.0), ("h", 3_600.0), ("m", 60.0)):
|
|
148
|
+
if seconds >= size and seconds % size == 0:
|
|
149
|
+
return f"{int(seconds // size)}{unit}"
|
|
150
|
+
return f"{seconds:g}s"
|
|
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|
|
20
20
|
import hashlib
|
|
21
21
|
import logging
|
|
22
22
|
from pathlib import Path
|
|
23
|
+
from typing import NamedTuple
|
|
23
24
|
|
|
24
25
|
from sbxloop.daemon.sources import WorkSource, parse_markdown_item
|
|
25
26
|
from sbxloop.daemon.store import DaemonStore
|
|
@@ -29,6 +30,84 @@ logger = logging.getLogger(__name__)
|
|
|
29
30
|
|
|
30
31
|
BACKLOG_SUBDIR = Path(".sbxloop") / "backlog"
|
|
31
32
|
|
|
33
|
+
# The audit lane's contract (discovery on GitHub): the deliverable is issues.
|
|
34
|
+
AUDIT_INSTRUCTIONS = (
|
|
35
|
+
"This is an AUDIT: your deliverable is a set of well-evidenced findings, not "
|
|
36
|
+
"code. Do not modify the tree except to write findings under "
|
|
37
|
+
"`.sbxloop/backlog/` in the workspace — one markdown file per finding, first "
|
|
38
|
+
"line `# <short, specific title>`, then these sections: **Evidence** (file:line "
|
|
39
|
+
"references and quoted lines that show the problem), **Repro / how to observe** "
|
|
40
|
+
"(a command, a test, or the exact scenario), **Proposal** (a one-line remedy), "
|
|
41
|
+
"**Size** (small | medium | large), **Kind** (bug | test | docs | refactor). "
|
|
42
|
+
"No finding without evidence. At most 5 findings — pick the ones that matter. "
|
|
43
|
+
"If the charter turns up nothing real, write no files and say so in your "
|
|
44
|
+
"summary: an empty result is a valid, honest outcome. Each file becomes a "
|
|
45
|
+
"GitHub issue for a human to triage. ROUTING: findings about THIS PROJECT's "
|
|
46
|
+
"code go directly under `.sbxloop/backlog/`. Findings about sbxloop itself — "
|
|
47
|
+
"the tool that ran you: its planner, prompts, verify lint, delivery, daemon — "
|
|
48
|
+
"go under `.sbxloop/backlog/tool/` instead; they are filed to the tool's own "
|
|
49
|
+
"tracker when the operator configured one, otherwise only noted in the closing "
|
|
50
|
+
"comment, never filed as issues of this project."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
TOOL_SUBDIR = BACKLOG_SUBDIR / "tool"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ToolFindings(NamedTuple):
|
|
57
|
+
filed: list[str] # refs filed upstream (tool_repo configured)
|
|
58
|
+
unfiled: list[str] # titles noted only (no tool_repo)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def collect_tool_findings(
|
|
62
|
+
run: RunRecord,
|
|
63
|
+
*,
|
|
64
|
+
dstore: DaemonStore,
|
|
65
|
+
source: WorkSource,
|
|
66
|
+
max_items: int,
|
|
67
|
+
now: float,
|
|
68
|
+
) -> ToolFindings:
|
|
69
|
+
"""Findings the run addressed to the TOOL (``.sbxloop/backlog/tool/``):
|
|
70
|
+
filed to the tool's tracker via ``source.file_tool_backlog`` when the
|
|
71
|
+
operator configured ``[daemon] tool_repo``, otherwise returned as
|
|
72
|
+
titles for the closing comment — never as issues of the project."""
|
|
73
|
+
if not run.mounted or run.workspace is None:
|
|
74
|
+
return ToolFindings([], [])
|
|
75
|
+
folder = run.workspace / TOOL_SUBDIR
|
|
76
|
+
if not folder.is_dir():
|
|
77
|
+
return ToolFindings([], [])
|
|
78
|
+
filer = getattr(source, "file_tool_backlog", None)
|
|
79
|
+
filed: list[str] = []
|
|
80
|
+
unfiled: list[str] = []
|
|
81
|
+
for path in sorted(folder.glob("*.md")):
|
|
82
|
+
try:
|
|
83
|
+
title, body = parse_markdown_item(path.read_text(), path.stem)
|
|
84
|
+
except OSError:
|
|
85
|
+
continue
|
|
86
|
+
if filer is None:
|
|
87
|
+
unfiled.append(title)
|
|
88
|
+
continue
|
|
89
|
+
fp = fingerprint("tool:" + title, body)
|
|
90
|
+
if dstore.backlog_seen(fp):
|
|
91
|
+
continue
|
|
92
|
+
if len(filed) >= max_items:
|
|
93
|
+
unfiled.append(title)
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
ref = filer(title, body, run.run_id)
|
|
97
|
+
except Exception:
|
|
98
|
+
logger.warning(
|
|
99
|
+
"tool finding %r from run %s could not be filed", title, run.run_id, exc_info=True
|
|
100
|
+
)
|
|
101
|
+
unfiled.append(title)
|
|
102
|
+
continue
|
|
103
|
+
if ref is None:
|
|
104
|
+
unfiled.append(title)
|
|
105
|
+
continue
|
|
106
|
+
dstore.backlog_record(fp, run.run_id, ref, now)
|
|
107
|
+
filed.append(ref)
|
|
108
|
+
return ToolFindings(filed, unfiled)
|
|
109
|
+
|
|
110
|
+
|
|
32
111
|
BACKLOG_INSTRUCTIONS = (
|
|
33
112
|
"If you identify follow-up work that is OUT OF SCOPE for this outcome, do "
|
|
34
113
|
"not expand scope. Write each such item as its own markdown file under "
|
|
@@ -891,7 +891,8 @@ def format_for_discord(
|
|
|
891
891
|
def _origin(item: WorkItem) -> tuple[str, str]:
|
|
892
892
|
"""(label, kind) for where a work item came from."""
|
|
893
893
|
if item.source == "github":
|
|
894
|
-
|
|
894
|
+
prefix = "audit" if item.kind == "audit" else "issue"
|
|
895
|
+
return f"{prefix} #{item.source_key}", "github"
|
|
895
896
|
return f"inbox {code(item.source_key)}", "inbox"
|
|
896
897
|
|
|
897
898
|
|
|
@@ -1037,6 +1038,8 @@ def items_lines(items: list[WorkItem], limit: int = 20) -> str:
|
|
|
1037
1038
|
rows = []
|
|
1038
1039
|
for i in items[:limit]:
|
|
1039
1040
|
row = f"{ITEM_STATE_MARKER.get(i.state, '•')} {code(i.item_id)} {i.state} · "
|
|
1041
|
+
if i.kind == "audit":
|
|
1042
|
+
row += "🔎 audit · "
|
|
1040
1043
|
row += link(_one_line(i.title, 60), i.url) if i.url else _one_line(i.title, 60)
|
|
1041
1044
|
row += f" · attempts {i.attempts}"
|
|
1042
1045
|
if i.run_id:
|
|
@@ -26,8 +26,16 @@ from typing import Any, NamedTuple, Protocol
|
|
|
26
26
|
|
|
27
27
|
from sbxloop import hostgit
|
|
28
28
|
from sbxloop.config import Config, GithubConfig, SandboxConfig
|
|
29
|
-
from sbxloop.daemon.
|
|
29
|
+
from sbxloop.daemon.audits import audit_marker, due_charters, issue_body, load_charters
|
|
30
|
+
from sbxloop.daemon.backlog import (
|
|
31
|
+
AUDIT_INSTRUCTIONS,
|
|
32
|
+
BACKLOG_INSTRUCTIONS,
|
|
33
|
+
ToolFindings,
|
|
34
|
+
collect_backlog,
|
|
35
|
+
collect_tool_findings,
|
|
36
|
+
)
|
|
30
37
|
from sbxloop.daemon.model import RunReport, TickOutcome, TickResult, WorkItem
|
|
38
|
+
from sbxloop.daemon.postmortem import build_dossier
|
|
31
39
|
from sbxloop.daemon.sources import WorkSource
|
|
32
40
|
from sbxloop.daemon.store import DaemonStore
|
|
33
41
|
from sbxloop.engine.engine import LoopEngine
|
|
@@ -49,6 +57,9 @@ from sbxloop.sbx.prune import remove_run_sandbox, remove_run_sandbox_secrets
|
|
|
49
57
|
|
|
50
58
|
logger = logging.getLogger(__name__)
|
|
51
59
|
|
|
60
|
+
# How often the audit scheduler fast-forwards the checkout to see new charters.
|
|
61
|
+
AUDIT_REFRESH_S = 600.0
|
|
62
|
+
|
|
52
63
|
|
|
53
64
|
class Frontend(Protocol):
|
|
54
65
|
"""What a human-facing channel (Discord) sees of the loop's lifecycle.
|
|
@@ -118,6 +129,8 @@ class DaemonLoop:
|
|
|
118
129
|
self._runner = runner or self._default_runner
|
|
119
130
|
self._stop = threading.Event()
|
|
120
131
|
self._paused = False
|
|
132
|
+
self._audit_problems_seen: set[str] = set()
|
|
133
|
+
self._last_audit_refresh = float("-inf")
|
|
121
134
|
self._current: RunHandle | None = None
|
|
122
135
|
self._current_lock = threading.Lock()
|
|
123
136
|
self._cancel_request: CancelRequest | None = None
|
|
@@ -346,6 +359,7 @@ class DaemonLoop:
|
|
|
346
359
|
f"daily run cap reached ({started_today}/{cap}); idling until the window rolls"
|
|
347
360
|
)
|
|
348
361
|
return TickResult(idle_kind="daily_cap")
|
|
362
|
+
self._schedule_audits(now)
|
|
349
363
|
discovered = self._discover(now)
|
|
350
364
|
item = self.dstore.next_queued(now, self.config.daemon.retry_backoff_s)
|
|
351
365
|
if item is None:
|
|
@@ -638,7 +652,11 @@ class DaemonLoop:
|
|
|
638
652
|
now = self.clock()
|
|
639
653
|
report = self._report(run_id, result)
|
|
640
654
|
if result is not None and report.succeeded:
|
|
641
|
-
self._collect_backlog(run_id, source)
|
|
655
|
+
filed = self._collect_backlog(run_id, source)
|
|
656
|
+
tool = self._collect_tool_findings(run_id, source)
|
|
657
|
+
report = report._replace(
|
|
658
|
+
filed=tuple(filed), tool_filed=tuple(tool.filed), tool_noted=tuple(tool.unfiled)
|
|
659
|
+
)
|
|
642
660
|
self.dstore.mark_done(item.item_id, now)
|
|
643
661
|
self.dstore.finish_ledger(run_id, "done", now)
|
|
644
662
|
self._set_breaker(None, 0)
|
|
@@ -647,7 +665,9 @@ class DaemonLoop:
|
|
|
647
665
|
self._notify(
|
|
648
666
|
f"✅ {item.item_id} done ({report.task_summary})"
|
|
649
667
|
+ (f" · PR {report.delivery[1]}" if report.delivery else "")
|
|
668
|
+
+ (f" · filed {len(report.filed)} finding(s)" if item.kind == "audit" else "")
|
|
650
669
|
)
|
|
670
|
+
self._file_review(item, run_id, report)
|
|
651
671
|
return "done"
|
|
652
672
|
if result is not None and result.state == "completed" and report.delivery_error:
|
|
653
673
|
# Work done, PR failed: a human must look; retrying would redo the work.
|
|
@@ -659,6 +679,7 @@ class DaemonLoop:
|
|
|
659
679
|
source.report_delivery_failed(item, report)
|
|
660
680
|
self._frontend_finished(item, report)
|
|
661
681
|
self._notify(f"⚠ {item.item_id} completed but delivery failed: {report.delivery_error}")
|
|
682
|
+
self._file_postmortem(item, run_id, f"delivery failed: {report.delivery_error}")
|
|
662
683
|
return "delivery_failed"
|
|
663
684
|
reason = str(error) if error is not None else f"run ended {report.state}"
|
|
664
685
|
attempts_left = self.config.daemon.max_attempts_per_item - item.attempts
|
|
@@ -673,6 +694,7 @@ class DaemonLoop:
|
|
|
673
694
|
self.dstore.mark_failed(item.item_id, reason, now, requeue=False)
|
|
674
695
|
source.report_abandoned(item, reason)
|
|
675
696
|
self._notify(f"❌ {item.item_id} abandoned after {item.attempts} attempt(s): {reason}")
|
|
697
|
+
self._file_postmortem(item, run_id, f"abandoned: {reason}")
|
|
676
698
|
outcome = "abandoned"
|
|
677
699
|
self._frontend_finished(item, report)
|
|
678
700
|
if self._consecutive_failures >= self.config.daemon.max_consecutive_failures:
|
|
@@ -738,7 +760,10 @@ class DaemonLoop:
|
|
|
738
760
|
# issue already is one (#251); the summary comment there
|
|
739
761
|
# carries the same information.
|
|
740
762
|
"report": self.config.daemon.tracking_issue,
|
|
741
|
-
|
|
763
|
+
# An audit's output is the issues it files; delivering
|
|
764
|
+
# its (deliberately unchanged) tree would only raise
|
|
765
|
+
# "nothing to deliver" and mis-settle it as failed.
|
|
766
|
+
"deliver": item.kind != "audit",
|
|
742
767
|
"deliver_draft": self.config.daemon.deliver_draft,
|
|
743
768
|
"create_repo": False,
|
|
744
769
|
}
|
|
@@ -807,7 +832,9 @@ class DaemonLoop:
|
|
|
807
832
|
else:
|
|
808
833
|
origin = f"inbox file `{item.source_key}`"
|
|
809
834
|
parts.append(f"---\nThis work item came from: {origin}.")
|
|
810
|
-
if
|
|
835
|
+
if item.kind == "audit":
|
|
836
|
+
parts.append(AUDIT_INSTRUCTIONS)
|
|
837
|
+
elif self.config.daemon.backlog != "off":
|
|
811
838
|
parts.append(BACKLOG_INSTRUCTIONS)
|
|
812
839
|
return "\n\n".join(parts)
|
|
813
840
|
|
|
@@ -853,14 +880,136 @@ class DaemonLoop:
|
|
|
853
880
|
workspace = str(result.workspace) if result is not None and result.workspace else None
|
|
854
881
|
return RunReport(run_id, state, summary, tracking, delivery, delivery_error, workspace)
|
|
855
882
|
|
|
856
|
-
def
|
|
883
|
+
def _schedule_audits(self, now: float) -> None:
|
|
884
|
+
"""Open due charters from the checkout's ``audit_dir`` as audit issues.
|
|
885
|
+
|
|
886
|
+
Runs after the pause/breaker/cap gates (a stressed daemon does not
|
|
887
|
+
add work for itself) and before discovery, so a freshly filed audit
|
|
888
|
+
is picked up in the same tick. GitHub is the schedule's source of
|
|
889
|
+
truth (a still-open audit is never re-filed; one created within the
|
|
890
|
+
interval counts as filed) with the store as a cache; a GitHub
|
|
891
|
+
hiccup skips this tick, never raises."""
|
|
892
|
+
daemon = self.config.daemon
|
|
893
|
+
if not daemon.audits:
|
|
894
|
+
return
|
|
895
|
+
github: Any = next((s for s in self.sources if s.name == "github"), None)
|
|
896
|
+
if github is None or not hasattr(github, "file_audit"):
|
|
897
|
+
return
|
|
898
|
+
checkout = self._workspace_checkout()
|
|
899
|
+
if checkout is None:
|
|
900
|
+
return
|
|
901
|
+
# The checkout is normally refreshed only when a run starts, so
|
|
902
|
+
# charters merged after the last run would never be seen (field:
|
|
903
|
+
# the first deploy read a clone that predated its own charters).
|
|
904
|
+
# Refresh here too, throttled — a fetch a minute is not the point.
|
|
905
|
+
if now - self._last_audit_refresh >= AUDIT_REFRESH_S:
|
|
906
|
+
self._last_audit_refresh = now
|
|
907
|
+
self._refresh_workspace()
|
|
908
|
+
charters, problems = load_charters(checkout, daemon.audit_dir)
|
|
909
|
+
for problem in problems:
|
|
910
|
+
if problem not in self._audit_problems_seen:
|
|
911
|
+
self._audit_problems_seen.add(problem)
|
|
912
|
+
self._notify(f"⚠ audit charter skipped: {problem}")
|
|
913
|
+
for charter in due_charters(charters, self.dstore.audit_last_filed(), now):
|
|
914
|
+
try:
|
|
915
|
+
since = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now - charter.every_s))
|
|
916
|
+
is_open, filed_recently = github.audit_issue_state(charter.title, since)
|
|
917
|
+
if is_open or filed_recently:
|
|
918
|
+
# Someone (or a previous process) already filed it: sync
|
|
919
|
+
# the cache so we stop asking GitHub every tick.
|
|
920
|
+
self.dstore.record_audit(charter.name, "gh:existing", now)
|
|
921
|
+
continue
|
|
922
|
+
ref = github.file_audit(
|
|
923
|
+
charter.title, issue_body(charter, audit_marker(charter.name))
|
|
924
|
+
)
|
|
925
|
+
self.dstore.record_audit(charter.name, ref, now)
|
|
926
|
+
self._notify(f"🔎 scheduled audit filed: {charter.title} → {ref}")
|
|
927
|
+
except Exception:
|
|
928
|
+
logger.warning("scheduled audit %s could not be filed", charter.name, exc_info=True)
|
|
929
|
+
|
|
930
|
+
def _collect_tool_findings(self, run_id: str, source: WorkSource) -> ToolFindings:
|
|
931
|
+
"""Findings addressed to the tool: filed upstream when tool_repo is
|
|
932
|
+
set, otherwise noted for the closing comment. Never the project's."""
|
|
933
|
+
if self.config.daemon.backlog == "off":
|
|
934
|
+
return ToolFindings([], [])
|
|
935
|
+
target = next((s for s in self.sources if s.name == self.config.daemon.backlog), None)
|
|
936
|
+
if target is None:
|
|
937
|
+
return ToolFindings([], [])
|
|
938
|
+
try:
|
|
939
|
+
return collect_tool_findings(
|
|
940
|
+
self.store.get_run(run_id),
|
|
941
|
+
dstore=self.dstore,
|
|
942
|
+
source=target,
|
|
943
|
+
max_items=self.config.daemon.backlog_max_per_run,
|
|
944
|
+
now=self.clock(),
|
|
945
|
+
)
|
|
946
|
+
except SbxloopError:
|
|
947
|
+
logger.warning("tool-finding collection failed for %s", run_id, exc_info=True)
|
|
948
|
+
return ToolFindings([], [])
|
|
949
|
+
|
|
950
|
+
def _file_review(self, item: WorkItem, run_id: str, report: RunReport) -> None:
|
|
951
|
+
"""The loop evaluating the code it just wrote: after a patch item
|
|
952
|
+
delivers a PR, open a review audit of that PR. Once per run, patch
|
|
953
|
+
items only (an audit has no PR), capped per rolling day, best-effort."""
|
|
954
|
+
daemon = self.config.daemon
|
|
955
|
+
if not daemon.review_deliveries or item.kind != "patch" or report.delivery is None:
|
|
956
|
+
return
|
|
957
|
+
github: Any = next((s for s in self.sources if s.name == "github"), None)
|
|
958
|
+
if github is None or not hasattr(github, "file_review"):
|
|
959
|
+
return
|
|
960
|
+
now = self.clock()
|
|
961
|
+
try:
|
|
962
|
+
if self.dstore.review_filed(run_id):
|
|
963
|
+
return
|
|
964
|
+
if self.dstore.reviews_since(now - DAY_S) >= daemon.reviews_per_day:
|
|
965
|
+
logger.info("delivery review for %s skipped: daily cap reached", run_id)
|
|
966
|
+
return
|
|
967
|
+
number, url = report.delivery
|
|
968
|
+
ref = github.file_review(item, number, url, run_id)
|
|
969
|
+
self.dstore.record_review(run_id, number, ref, now)
|
|
970
|
+
self._notify(f"🔎 review audit filed for PR #{number}: {ref}")
|
|
971
|
+
except Exception:
|
|
972
|
+
logger.warning("delivery review for %s could not be filed", run_id, exc_info=True)
|
|
973
|
+
|
|
974
|
+
def _file_postmortem(self, item: WorkItem, run_id: str, reason: str) -> None:
|
|
975
|
+
"""Turn the daemon's own failure into a discovery-lane charter.
|
|
976
|
+
|
|
977
|
+
Only for patch items — a failed audit filing a post-mortem that is
|
|
978
|
+
itself an audit would recurse — only once per run, and capped per
|
|
979
|
+
rolling day so a bad night cannot flood the tracker. Best-effort:
|
|
980
|
+
the item is already settled; this must never change that."""
|
|
981
|
+
daemon = self.config.daemon
|
|
982
|
+
if not daemon.postmortems or item.kind != "patch":
|
|
983
|
+
return
|
|
984
|
+
github: Any = next((s for s in self.sources if s.name == "github"), None)
|
|
985
|
+
if github is None or not hasattr(github, "file_postmortem"):
|
|
986
|
+
return
|
|
987
|
+
now = self.clock()
|
|
988
|
+
try:
|
|
989
|
+
if self.dstore.postmortem_filed(run_id):
|
|
990
|
+
return
|
|
991
|
+
if self.dstore.postmortems_since(now - DAY_S) >= daemon.postmortems_per_day:
|
|
992
|
+
logger.info("post-mortem for %s skipped: daily cap reached", run_id)
|
|
993
|
+
return
|
|
994
|
+
run_ids = self.dstore.runs_for_item(item.item_id) or [run_id]
|
|
995
|
+
dossier = build_dossier(
|
|
996
|
+
self.store, item, run_ids, reason, state_dir=str(self.config.state_dir)
|
|
997
|
+
)
|
|
998
|
+
ref = github.file_postmortem(item, dossier, run_id)
|
|
999
|
+
self.dstore.record_postmortem(run_id, item.item_id, ref, now)
|
|
1000
|
+
self._notify(f"🔎 post-mortem filed for {item.item_id}: {ref}")
|
|
1001
|
+
except Exception:
|
|
1002
|
+
logger.warning("post-mortem for %s could not be filed", run_id, exc_info=True)
|
|
1003
|
+
|
|
1004
|
+
def _collect_backlog(self, run_id: str, source: WorkSource) -> list[str]:
|
|
1005
|
+
"""File the run's backlog items; returns their refs (``gh:<n>``)."""
|
|
857
1006
|
mode = self.config.daemon.backlog
|
|
858
1007
|
if mode == "off":
|
|
859
|
-
return
|
|
1008
|
+
return []
|
|
860
1009
|
target = next((s for s in self.sources if s.name == mode), None)
|
|
861
1010
|
if target is None:
|
|
862
1011
|
logger.warning("backlog mode %r but no such source is active", mode)
|
|
863
|
-
return
|
|
1012
|
+
return []
|
|
864
1013
|
try:
|
|
865
1014
|
record = self.store.get_run(run_id)
|
|
866
1015
|
filed = collect_backlog(
|
|
@@ -873,9 +1022,10 @@ class DaemonLoop:
|
|
|
873
1022
|
)
|
|
874
1023
|
except SbxloopError:
|
|
875
1024
|
logger.warning("backlog collection failed for %s", run_id, exc_info=True)
|
|
876
|
-
return
|
|
1025
|
+
return []
|
|
877
1026
|
if filed:
|
|
878
1027
|
self._notify(f"filed {len(filed)} backlog item(s) from {run_id}: {', '.join(filed)}")
|
|
1028
|
+
return list(filed)
|
|
879
1029
|
|
|
880
1030
|
# -- recovery ------------------------------------------------------------------------
|
|
881
1031
|
|
|
@@ -9,6 +9,10 @@ from pydantic import BaseModel, ConfigDict
|
|
|
9
9
|
from sbxloop.engine.model import RunState
|
|
10
10
|
|
|
11
11
|
ItemSource = Literal["github", "inbox"]
|
|
12
|
+
# What a run of the item is FOR. ``patch``: change the tree, deliver a PR.
|
|
13
|
+
# ``audit``: investigate and file findings as backlog issues — no delivery,
|
|
14
|
+
# the tree is disposable, the issues are the output (the discovery lane).
|
|
15
|
+
ItemKind = Literal["patch", "audit"]
|
|
12
16
|
# ``cancelled`` is an operator's decision (``!sbx cancel``), not a failure:
|
|
13
17
|
# it is terminal for the daemon (no retry, no breaker count) while the run
|
|
14
18
|
# itself stays resumable from the CLI.
|
|
@@ -32,6 +36,7 @@ class WorkItem(BaseModel):
|
|
|
32
36
|
item_id: str
|
|
33
37
|
source: ItemSource
|
|
34
38
|
source_key: str
|
|
39
|
+
kind: ItemKind = "patch"
|
|
35
40
|
title: str
|
|
36
41
|
body: str = ""
|
|
37
42
|
url: str = ""
|
|
@@ -62,6 +67,12 @@ class RunReport(NamedTuple):
|
|
|
62
67
|
cancelled_by: str | None = None
|
|
63
68
|
# ``!sbx cancel --retry``: the item went straight back to the queue.
|
|
64
69
|
requeued: bool = False
|
|
70
|
+
# Backlog issues the run filed (``gh:<n>`` refs) — an audit's deliverable.
|
|
71
|
+
filed: tuple[str, ...] = ()
|
|
72
|
+
# Findings addressed to sbxloop itself: filed upstream (``[daemon]
|
|
73
|
+
# tool_repo``) as refs, or merely noted by title in the closing comment.
|
|
74
|
+
tool_filed: tuple[str, ...] = ()
|
|
75
|
+
tool_noted: tuple[str, ...] = ()
|
|
65
76
|
|
|
66
77
|
@property
|
|
67
78
|
def succeeded(self) -> bool:
|