sbxloop 0.6.0__tar.gz → 0.6.2__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.6.0 → sbxloop-0.6.2}/PKG-INFO +3 -1
- {sbxloop-0.6.0 → sbxloop-0.6.2}/pyproject.toml +3 -0
- sbxloop-0.6.0/src/sbxloop/_vendor/sbxloop_worker-0.6.0-py3-none-any.whl → sbxloop-0.6.2/src/sbxloop/_vendor/sbxloop_worker-0.6.2-py3-none-any.whl +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/_version.py +2 -2
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/cli/app.py +190 -1
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/cli/doctor.py +19 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/config.py +95 -0
- sbxloop-0.6.2/src/sbxloop/daemon/__init__.py +10 -0
- sbxloop-0.6.2/src/sbxloop/daemon/backlog.py +95 -0
- sbxloop-0.6.2/src/sbxloop/daemon/discord.py +703 -0
- sbxloop-0.6.2/src/sbxloop/daemon/github.py +128 -0
- sbxloop-0.6.2/src/sbxloop/daemon/loop.py +569 -0
- sbxloop-0.6.2/src/sbxloop/daemon/model.py +76 -0
- sbxloop-0.6.2/src/sbxloop/daemon/sources.py +363 -0
- sbxloop-0.6.2/src/sbxloop/daemon/store.py +287 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/prompts/plan.md +20 -2
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/errors.py +5 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/hostgit.py +10 -3
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/provision.py +72 -1
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/verifylint.py +94 -2
- {sbxloop-0.6.0 → sbxloop-0.6.2}/.gitignore +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/README.md +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/hatch_build.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/__init__.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/cli/__init__.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/cli/models.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/cli/tui.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/deliver.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/__init__.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/engine.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/model.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/phases.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/prompts/__init__.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/prompts/decompose.md +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/prompts/execute.md +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/prompts/steer.md +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/prompts/validate.md +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/store.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/events.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/gh/__init__.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/gh/ops.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/gh/reporter.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/ids.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/policy.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/py.typed +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/__init__.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/bake.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/cli.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/conformance.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/models.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/pair.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/parse.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/prune.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/sandbox.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/secretstate.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/toolchains.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/worker/__init__.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/worker/client.py +0 -0
- {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/worker/wheel.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sbxloop
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
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
|
|
@@ -23,6 +23,8 @@ Requires-Dist: sbxloop-worker
|
|
|
23
23
|
Requires-Dist: typer>=0.12
|
|
24
24
|
Provides-Extra: copilot
|
|
25
25
|
Requires-Dist: github-copilot-sdk>=1.0.8; extra == 'copilot'
|
|
26
|
+
Provides-Extra: discord
|
|
27
|
+
Requires-Dist: discord-py>=2.3; extra == 'discord'
|
|
26
28
|
Description-Content-Type: text/markdown
|
|
27
29
|
|
|
28
30
|
# sbxloop
|
|
@@ -31,6 +31,9 @@ dependencies = [
|
|
|
31
31
|
# Host-side SDK access for `sbxloop list-models` (agent sessions get the SDK
|
|
32
32
|
# inside the sandbox via sbxloop-worker[copilot]; runs never need this extra).
|
|
33
33
|
copilot = ["github-copilot-sdk>=1.0.8"]
|
|
34
|
+
# `sbxloop daemon`'s Discord bridge (chronology + steering); the daemon runs
|
|
35
|
+
# without it when [discord] is unset.
|
|
36
|
+
discord = ["discord.py>=2.3"]
|
|
34
37
|
|
|
35
38
|
[project.scripts]
|
|
36
39
|
sbxloop = "sbxloop.cli.app:main"
|
|
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.6.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 6,
|
|
21
|
+
__version__ = version = '0.6.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 6, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -25,6 +25,8 @@ from sbxloop.cli.doctor import run_doctor
|
|
|
25
25
|
from sbxloop.cli.tui import ChatInput, Dashboard, format_event, plain_printer, render_event
|
|
26
26
|
from sbxloop.config import (
|
|
27
27
|
Config,
|
|
28
|
+
DaemonConfig,
|
|
29
|
+
DiscordConfig,
|
|
28
30
|
GithubConfig,
|
|
29
31
|
load_config,
|
|
30
32
|
load_config_with_sources,
|
|
@@ -34,7 +36,7 @@ from sbxloop.engine.engine import LoopEngine
|
|
|
34
36
|
from sbxloop.engine.model import TERMINAL_RUN_STATES, RunResult, artifacts_dir, scan_artifacts
|
|
35
37
|
from sbxloop.engine.store import StateStore
|
|
36
38
|
from sbxloop.errors import SbxloopError
|
|
37
|
-
from sbxloop.events import Event, HostEventTypes
|
|
39
|
+
from sbxloop.events import Event, EventBus, HostEventTypes
|
|
38
40
|
from sbxloop.sbx.bake import DEFAULT_TEMPLATE_REF, bake_template
|
|
39
41
|
from sbxloop.sbx.cli import SbxCLI
|
|
40
42
|
from sbxloop.sbx.models import SandboxRole
|
|
@@ -1204,6 +1206,155 @@ def init(
|
|
|
1204
1206
|
console.print(f"wrote {path}")
|
|
1205
1207
|
|
|
1206
1208
|
|
|
1209
|
+
@app.command()
|
|
1210
|
+
def daemon(
|
|
1211
|
+
repo: Annotated[
|
|
1212
|
+
str | None,
|
|
1213
|
+
typer.Option("--repo", help='GitHub repository ("owner/name") to poll for labeled issues.'),
|
|
1214
|
+
] = None,
|
|
1215
|
+
inbox: Annotated[
|
|
1216
|
+
str | None,
|
|
1217
|
+
typer.Option("--inbox", help='Inbox directory of .md work files ("" disables).'),
|
|
1218
|
+
] = None,
|
|
1219
|
+
backlog: Annotated[
|
|
1220
|
+
str | None,
|
|
1221
|
+
typer.Option("--backlog", help="Where agent-filed follow-up work goes: github|inbox|off."),
|
|
1222
|
+
] = None,
|
|
1223
|
+
max_runs_per_day: Annotated[
|
|
1224
|
+
int | None, typer.Option("--max-runs-per-day", help="Rolling 24h run cap.")
|
|
1225
|
+
] = None,
|
|
1226
|
+
poll_interval: Annotated[
|
|
1227
|
+
float | None, typer.Option("--poll-interval", help="Seconds between polls.")
|
|
1228
|
+
] = None,
|
|
1229
|
+
discord_channel: Annotated[
|
|
1230
|
+
int | None,
|
|
1231
|
+
typer.Option("--discord-channel", help="Discord control channel id (enables the bridge)."),
|
|
1232
|
+
] = None,
|
|
1233
|
+
once: Annotated[
|
|
1234
|
+
bool, typer.Option("--once", help="Recover, run one tick, exit (cron / smoke tests).")
|
|
1235
|
+
] = False,
|
|
1236
|
+
dry_run: Annotated[
|
|
1237
|
+
bool, typer.Option("--dry-run", help="Poll and print candidates; claim and run nothing.")
|
|
1238
|
+
] = False,
|
|
1239
|
+
) -> None:
|
|
1240
|
+
"""Run the always-on outer loop: discover work (labeled GitHub issues,
|
|
1241
|
+
inbox files), run each item through the inner loop, report back, and
|
|
1242
|
+
mirror the chronology to Discord."""
|
|
1243
|
+
import logging
|
|
1244
|
+
|
|
1245
|
+
from sbxloop.daemon.discord import DiscordBridge
|
|
1246
|
+
from sbxloop.daemon.github import DaemonGithub
|
|
1247
|
+
from sbxloop.daemon.loop import DaemonLoop
|
|
1248
|
+
from sbxloop.daemon.sources import GitHubIssueSource, GitHubLabels, InboxSource, WorkSource
|
|
1249
|
+
from sbxloop.daemon.store import DaemonStore
|
|
1250
|
+
|
|
1251
|
+
logging.basicConfig(
|
|
1252
|
+
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
|
1253
|
+
)
|
|
1254
|
+
config = load_config()
|
|
1255
|
+
daemon_overrides = {
|
|
1256
|
+
k: v
|
|
1257
|
+
for k, v in (
|
|
1258
|
+
("inbox_dir", inbox),
|
|
1259
|
+
("backlog", backlog),
|
|
1260
|
+
("max_runs_per_day", max_runs_per_day),
|
|
1261
|
+
("poll_interval_s", poll_interval),
|
|
1262
|
+
)
|
|
1263
|
+
if v is not None
|
|
1264
|
+
}
|
|
1265
|
+
try:
|
|
1266
|
+
if daemon_overrides:
|
|
1267
|
+
daemon_cfg = DaemonConfig.model_validate(
|
|
1268
|
+
{**config.daemon.model_dump(), **daemon_overrides}
|
|
1269
|
+
)
|
|
1270
|
+
config = config.model_copy(update={"daemon": daemon_cfg})
|
|
1271
|
+
if repo is not None:
|
|
1272
|
+
github_cfg = GithubConfig.model_validate({**config.github.model_dump(), "repo": repo})
|
|
1273
|
+
config = config.model_copy(update={"github": github_cfg})
|
|
1274
|
+
if discord_channel is not None:
|
|
1275
|
+
discord_cfg = DiscordConfig.model_validate(
|
|
1276
|
+
{**config.discord.model_dump(), "channel_id": discord_channel}
|
|
1277
|
+
)
|
|
1278
|
+
config = config.model_copy(update={"discord": discord_cfg})
|
|
1279
|
+
except ValidationError as exc:
|
|
1280
|
+
console.print(f"[bold red]invalid daemon option:[/] {exc.errors()[0]['msg']}")
|
|
1281
|
+
raise typer.Exit(2) from exc
|
|
1282
|
+
|
|
1283
|
+
github_active = config.github.enabled
|
|
1284
|
+
inbox_active = bool(config.daemon.inbox_dir)
|
|
1285
|
+
if not github_active and not inbox_active:
|
|
1286
|
+
console.print(
|
|
1287
|
+
"[bold red]no work sources:[/] set [cyan]--repo owner/name[/] (or "
|
|
1288
|
+
"[cyan]\\[github] repo[/]) to poll issues, and/or an inbox directory "
|
|
1289
|
+
"([cyan]--inbox DIR[/] / [cyan]\\[daemon] inbox_dir[/])."
|
|
1290
|
+
)
|
|
1291
|
+
raise typer.Exit(2)
|
|
1292
|
+
if config.daemon.backlog == "github" and not github_active:
|
|
1293
|
+
console.print("[bold red]--backlog github needs a GitHub repository[/] (--repo).")
|
|
1294
|
+
raise typer.Exit(2)
|
|
1295
|
+
|
|
1296
|
+
store = _store(config)
|
|
1297
|
+
dstore = DaemonStore(config.state_dir / "state.db")
|
|
1298
|
+
sbx = SbxCLI(app_name=config.app_name or None)
|
|
1299
|
+
bus = EventBus()
|
|
1300
|
+
bus.subscribe(plain_printer(console))
|
|
1301
|
+
sources: list[WorkSource] = []
|
|
1302
|
+
github: DaemonGithub | None = None
|
|
1303
|
+
if inbox_active:
|
|
1304
|
+
sources.append(InboxSource(Path(config.daemon.inbox_dir)))
|
|
1305
|
+
if github_active:
|
|
1306
|
+
assert config.github.repo is not None
|
|
1307
|
+
github = DaemonGithub(config, sbx, bus, worker_python=config.worker_python)
|
|
1308
|
+
github.remove_stale()
|
|
1309
|
+
labels = GitHubLabels(
|
|
1310
|
+
config.daemon.trigger_label,
|
|
1311
|
+
config.daemon.in_progress_label,
|
|
1312
|
+
config.daemon.failed_label,
|
|
1313
|
+
config.daemon.backlog_label,
|
|
1314
|
+
)
|
|
1315
|
+
sources.append(GitHubIssueSource(github.ops, config.github.repo, labels))
|
|
1316
|
+
|
|
1317
|
+
if dry_run:
|
|
1318
|
+
for source in sources:
|
|
1319
|
+
for item in source.poll():
|
|
1320
|
+
console.print(
|
|
1321
|
+
f"[cyan]{item.item_id}[/] {item.title}" + (f" {item.url}" if item.url else "")
|
|
1322
|
+
)
|
|
1323
|
+
if github is not None:
|
|
1324
|
+
github.close()
|
|
1325
|
+
raise typer.Exit(0)
|
|
1326
|
+
|
|
1327
|
+
loop = DaemonLoop(config, store=store, dstore=dstore, sources=sources, sbx=sbx)
|
|
1328
|
+
bridge: DiscordBridge | None = None
|
|
1329
|
+
if config.discord.enabled:
|
|
1330
|
+
try:
|
|
1331
|
+
bridge = DiscordBridge(config, dstore, loop_ref=loop)
|
|
1332
|
+
bridge.start()
|
|
1333
|
+
except SbxloopError as exc:
|
|
1334
|
+
console.print(f"[bold red]discord bridge:[/] {exc}")
|
|
1335
|
+
raise typer.Exit(2) from exc
|
|
1336
|
+
loop.frontend = bridge
|
|
1337
|
+
|
|
1338
|
+
cleanup_registry.install_handlers()
|
|
1339
|
+
cleanup_registry.set_quiesce(loop.quiesce)
|
|
1340
|
+
try:
|
|
1341
|
+
loop.recover()
|
|
1342
|
+
if once:
|
|
1343
|
+
result = loop.tick()
|
|
1344
|
+
console.print(f"tick: {result}")
|
|
1345
|
+
else:
|
|
1346
|
+
loop.run_forever()
|
|
1347
|
+
except KeyboardInterrupt:
|
|
1348
|
+
console.print("\n[bold yellow]daemon interrupted[/]")
|
|
1349
|
+
finally:
|
|
1350
|
+
cleanup_registry.set_quiesce(None)
|
|
1351
|
+
if bridge is not None:
|
|
1352
|
+
bridge.close()
|
|
1353
|
+
if github is not None:
|
|
1354
|
+
github.close()
|
|
1355
|
+
dstore.close()
|
|
1356
|
+
|
|
1357
|
+
|
|
1207
1358
|
@app.command()
|
|
1208
1359
|
def bake(
|
|
1209
1360
|
ref: Annotated[
|
|
@@ -1448,6 +1599,44 @@ per_job_timeout_s = 1800.0
|
|
|
1448
1599
|
disk_warn = 85.0
|
|
1449
1600
|
disk_abort = 95.0
|
|
1450
1601
|
mem_warn = 90.0
|
|
1602
|
+
|
|
1603
|
+
[daemon]
|
|
1604
|
+
# `sbxloop daemon` — the always-on outer loop. Discovers work and runs each
|
|
1605
|
+
# item as a full inner-loop run, fully autonomously: a labeled issue in the
|
|
1606
|
+
# [github] repo or a .md file in the inbox starts a run on its own, so the
|
|
1607
|
+
# guardrails below are what stand between a mislabeled issue and your budget.
|
|
1608
|
+
# inbox_dir = ".sbxloop/inbox" # "" disables the inbox source
|
|
1609
|
+
# poll_interval_s = 60.0
|
|
1610
|
+
# trigger_label = "sbxloop:run" # issue label that queues work
|
|
1611
|
+
# in_progress_label = "sbxloop:in-progress"
|
|
1612
|
+
# failed_label = "sbxloop:failed"
|
|
1613
|
+
# backlog_label = "sbxloop:backlog"
|
|
1614
|
+
# max_runs_per_day = 12 # rolling 24h window, persisted across restarts
|
|
1615
|
+
# max_attempts_per_item = 2
|
|
1616
|
+
# retry_backoff_s = 900.0 # times the attempt number
|
|
1617
|
+
# max_consecutive_failures = 3 # circuit breaker ...
|
|
1618
|
+
# breaker_cooldown_s = 3600.0 # ... and how long it stays open
|
|
1619
|
+
# shutdown_grace_s = 60.0 # keep below systemd TimeoutStopSec
|
|
1620
|
+
# Agent-filed follow-up work (.sbxloop/backlog/*.md in the run workspace):
|
|
1621
|
+
# "github" files issues with backlog_label, "inbox" writes to inbox/triage/,
|
|
1622
|
+
# "off" ignores it. Filed items wait for a human unless auto-trigger is on.
|
|
1623
|
+
# backlog = "off"
|
|
1624
|
+
# backlog_max_per_run = 5
|
|
1625
|
+
# backlog_auto_trigger = false
|
|
1626
|
+
# deliver_draft = true # autonomous PRs arrive as drafts
|
|
1627
|
+
|
|
1628
|
+
[discord]
|
|
1629
|
+
# The daemon's human channel: a gateway bot posts each run's chronology
|
|
1630
|
+
# (agent messages, tool lines, issue/PR links) into a thread under a control
|
|
1631
|
+
# channel and relays replies typed in the thread to the running agent as
|
|
1632
|
+
# steering. Needs `pip install 'sbxloop[discord]'` and DISCORD_BOT_TOKEN in
|
|
1633
|
+
# the environment / .env (never here). Anyone who can post in the channel can
|
|
1634
|
+
# steer — restrict the channel. Unset channel_id = Discord off.
|
|
1635
|
+
# channel_id = 123456789012345678
|
|
1636
|
+
# command_prefix = "!sbx" # !sbx status|pause|resume|cancel|queue
|
|
1637
|
+
# thread_per_run = true
|
|
1638
|
+
# chronology_level = "normal" # quiet | normal | verbose
|
|
1639
|
+
# max_message_chars = 1900
|
|
1451
1640
|
"""
|
|
1452
1641
|
|
|
1453
1642
|
|
|
@@ -295,6 +295,25 @@ def collect_checks(
|
|
|
295
295
|
)
|
|
296
296
|
)
|
|
297
297
|
|
|
298
|
+
# daemon's Discord bridge (only when configured)
|
|
299
|
+
if config.discord.enabled:
|
|
300
|
+
problems: list[str] = []
|
|
301
|
+
try:
|
|
302
|
+
import discord as _discordpy # noqa: F401
|
|
303
|
+
except ImportError:
|
|
304
|
+
problems.append("discord.py missing (pip install 'sbxloop[discord]')")
|
|
305
|
+
if not env.get("DISCORD_BOT_TOKEN"):
|
|
306
|
+
problems.append("DISCORD_BOT_TOKEN not set")
|
|
307
|
+
checks.append(
|
|
308
|
+
Check(
|
|
309
|
+
"discord bridge",
|
|
310
|
+
not problems,
|
|
311
|
+
f"channel {config.discord.channel_id}: "
|
|
312
|
+
+ ("; ".join(problems) if problems else "extra installed, token present"),
|
|
313
|
+
hard=False,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
|
|
298
317
|
# worker wheel
|
|
299
318
|
wheel = resolve_worker_wheel()
|
|
300
319
|
checks.append(
|
|
@@ -278,6 +278,99 @@ class Budgets(_ConfigModel):
|
|
|
278
278
|
per_job_timeout_s: float = 1800.0
|
|
279
279
|
|
|
280
280
|
|
|
281
|
+
BacklogMode = Literal["off", "github", "inbox"]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class DaemonConfig(_ConfigModel):
|
|
285
|
+
"""``sbxloop daemon`` — the always-on outer loop.
|
|
286
|
+
|
|
287
|
+
The daemon discovers work (GitHub issues carrying ``trigger_label`` in
|
|
288
|
+
the configured repo, and ``.md`` files in ``inbox_dir``), runs each item
|
|
289
|
+
as a full inner-loop run, and reports back to the source. It is fully
|
|
290
|
+
autonomous — a label or a file alone starts a run — so the spend
|
|
291
|
+
guardrails here are the only thing standing between a mislabeled issue
|
|
292
|
+
and an empty Copilot budget: a rolling daily run cap, a per-item retry
|
|
293
|
+
cap, and a consecutive-failure circuit breaker.
|
|
294
|
+
|
|
295
|
+
``backlog`` lets the inner agent file follow-up work it discovers
|
|
296
|
+
(written to ``.sbxloop/backlog/*.md`` in the run workspace) into either
|
|
297
|
+
source. Agent-filed items land in triage (the ``backlog_label`` / the
|
|
298
|
+
inbox ``triage/`` dir) and never run until a human promotes them, unless
|
|
299
|
+
``backlog_auto_trigger`` is set — a self-feeding queue is the failure
|
|
300
|
+
mode that flag guards.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
inbox_dir: str = ".sbxloop/inbox" # "" disables the inbox source
|
|
304
|
+
# Must be positive: Event.wait(<= 0) returns immediately and the loop spins.
|
|
305
|
+
poll_interval_s: float = Field(default=60.0, gt=0)
|
|
306
|
+
trigger_label: str = "sbxloop:run"
|
|
307
|
+
in_progress_label: str = "sbxloop:in-progress"
|
|
308
|
+
failed_label: str = "sbxloop:failed"
|
|
309
|
+
backlog_label: str = "sbxloop:backlog"
|
|
310
|
+
max_runs_per_day: int = 12
|
|
311
|
+
max_attempts_per_item: int = 2
|
|
312
|
+
retry_backoff_s: float = 900.0
|
|
313
|
+
max_consecutive_failures: int = 3
|
|
314
|
+
breaker_cooldown_s: float = 3600.0
|
|
315
|
+
# Keep below the service manager's stop timeout. Cancellation is honored
|
|
316
|
+
# only at task-phase boundaries and interrupted runs are resumable, so
|
|
317
|
+
# this is a courtesy wait, not a correctness requirement.
|
|
318
|
+
shutdown_grace_s: float = 60.0
|
|
319
|
+
backlog: BacklogMode = "off"
|
|
320
|
+
backlog_max_per_run: int = 5
|
|
321
|
+
backlog_auto_trigger: bool = False
|
|
322
|
+
# Autonomous PRs arrive as drafts unless the operator says otherwise.
|
|
323
|
+
deliver_draft: bool = True
|
|
324
|
+
|
|
325
|
+
@model_validator(mode="after")
|
|
326
|
+
def _check(self) -> DaemonConfig:
|
|
327
|
+
labels = [
|
|
328
|
+
self.trigger_label,
|
|
329
|
+
self.in_progress_label,
|
|
330
|
+
self.failed_label,
|
|
331
|
+
self.backlog_label,
|
|
332
|
+
]
|
|
333
|
+
if any(not label.strip() for label in labels):
|
|
334
|
+
raise ValueError("daemon labels must be non-empty")
|
|
335
|
+
# GitHub label names are case-insensitive: "sbxloop:run" and
|
|
336
|
+
# "SBXLOOP:RUN" are the same label, so they cannot mark two states.
|
|
337
|
+
if len({label.strip().casefold() for label in labels}) != len(labels):
|
|
338
|
+
raise ValueError("daemon labels must be distinct (case-insensitively)")
|
|
339
|
+
for name in (
|
|
340
|
+
"max_runs_per_day",
|
|
341
|
+
"max_attempts_per_item",
|
|
342
|
+
"max_consecutive_failures",
|
|
343
|
+
"backlog_max_per_run",
|
|
344
|
+
):
|
|
345
|
+
if getattr(self, name) < 1:
|
|
346
|
+
raise ValueError(f"daemon.{name} must be >= 1")
|
|
347
|
+
return self
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
ChronologyLevel = Literal["quiet", "normal", "verbose"]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class DiscordConfig(_ConfigModel):
|
|
354
|
+
"""The daemon's human channel: a gateway bot posting each run's
|
|
355
|
+
chronology (agent messages, tool lines, issue/PR links) into a thread
|
|
356
|
+
under a control channel, and relaying replies typed in that thread to
|
|
357
|
+
the running agent as steering. Unset ``channel_id`` disables it. The bot
|
|
358
|
+
token comes from ``DISCORD_BOT_TOKEN`` in the environment / .env, never
|
|
359
|
+
from this file. Anyone who can post in the channel can steer — restrict
|
|
360
|
+
the channel accordingly."""
|
|
361
|
+
|
|
362
|
+
channel_id: int | None = None
|
|
363
|
+
command_prefix: str = "!sbx"
|
|
364
|
+
thread_per_run: bool = True
|
|
365
|
+
chronology_level: ChronologyLevel = "normal"
|
|
366
|
+
# Discord's hard cap is 2000; leave headroom for wrappers.
|
|
367
|
+
max_message_chars: int = Field(default=1900, ge=200, le=2000)
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def enabled(self) -> bool:
|
|
371
|
+
return self.channel_id is not None
|
|
372
|
+
|
|
373
|
+
|
|
281
374
|
class Config(_ConfigModel):
|
|
282
375
|
model: str = "auto"
|
|
283
376
|
# sbx --app-name. Empty (the default) shares the user's normal sbx
|
|
@@ -305,6 +398,8 @@ class Config(_ConfigModel):
|
|
|
305
398
|
artifacts: ArtifactsConfig = Field(default_factory=ArtifactsConfig)
|
|
306
399
|
budgets: Budgets = Field(default_factory=Budgets)
|
|
307
400
|
limits: Limits = Field(default_factory=Limits)
|
|
401
|
+
daemon: DaemonConfig = Field(default_factory=DaemonConfig)
|
|
402
|
+
discord: DiscordConfig = Field(default_factory=DiscordConfig)
|
|
308
403
|
|
|
309
404
|
|
|
310
405
|
def _read_toml(path: Path) -> dict[str, Any]:
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""``sbxloop daemon`` — the always-on outer loop around the run engine.
|
|
2
|
+
|
|
3
|
+
Discovers work (GitHub issues by label, inbox ``.md`` files), runs each item
|
|
4
|
+
through a fresh :class:`~sbxloop.engine.engine.LoopEngine`, reports back to
|
|
5
|
+
the source, mirrors the chronology to Discord, and keeps going.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from sbxloop.daemon.model import RunReport, TickResult, WorkItem
|
|
9
|
+
|
|
10
|
+
__all__ = ["RunReport", "TickResult", "WorkItem"]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Agent-filed backlog: follow-up work the inner agent discovered.
|
|
2
|
+
|
|
3
|
+
The agent sandbox holds no GH_TOKEN, so an agent cannot file issues; it
|
|
4
|
+
writes ``.sbxloop/backlog/*.md`` files in the run workspace instead, and
|
|
5
|
+
the daemon files them after the run through whichever source ``--backlog``
|
|
6
|
+
names. ``.sbxloop`` sits in the default artifact excludes, which is exactly
|
|
7
|
+
what keeps these files out of harvest and out of the delivery PR — and also
|
|
8
|
+
why collection reads the mounted workspace directly (unmounted runs are
|
|
9
|
+
skipped with a loud warning).
|
|
10
|
+
|
|
11
|
+
Filed items land in triage by default (backlog label / inbox ``triage/``);
|
|
12
|
+
``backlog_auto_trigger`` puts them straight into the queue. Fingerprints
|
|
13
|
+
dedup re-collection on resume, and ``max_items`` caps how much queue one
|
|
14
|
+
run may generate — an agent enumerating infinite future work must not
|
|
15
|
+
flood the daemon.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import logging
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from sbxloop.daemon.sources import WorkSource, parse_markdown_item
|
|
25
|
+
from sbxloop.daemon.store import DaemonStore
|
|
26
|
+
from sbxloop.engine.model import RunRecord
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
BACKLOG_SUBDIR = Path(".sbxloop") / "backlog"
|
|
31
|
+
|
|
32
|
+
BACKLOG_INSTRUCTIONS = (
|
|
33
|
+
"If you identify follow-up work that is OUT OF SCOPE for this outcome, do "
|
|
34
|
+
"not expand scope. Write each such item as its own markdown file under "
|
|
35
|
+
"`.sbxloop/backlog/` in the workspace — first line `# <short title>`, then a "
|
|
36
|
+
"paragraph describing the work and why it matters. These are triaged and "
|
|
37
|
+
"filed automatically after the run."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fingerprint(title: str, body: str) -> str:
|
|
42
|
+
return hashlib.sha256(f"{title}\n{body}".encode()).hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def collect_backlog(
|
|
46
|
+
run: RunRecord,
|
|
47
|
+
*,
|
|
48
|
+
dstore: DaemonStore,
|
|
49
|
+
source: WorkSource,
|
|
50
|
+
max_items: int,
|
|
51
|
+
trigger: bool,
|
|
52
|
+
now: float,
|
|
53
|
+
) -> list[str]:
|
|
54
|
+
"""File the run's backlog items via ``source``; returns the refs filed."""
|
|
55
|
+
if not run.mounted or run.workspace is None:
|
|
56
|
+
logger.warning(
|
|
57
|
+
"backlog skipped for run %s: workspace not mounted (agent-filed backlog "
|
|
58
|
+
"requires the mounted-workspace mode)",
|
|
59
|
+
run.run_id,
|
|
60
|
+
)
|
|
61
|
+
return []
|
|
62
|
+
folder = run.workspace / BACKLOG_SUBDIR
|
|
63
|
+
if not folder.is_dir():
|
|
64
|
+
return []
|
|
65
|
+
filed: list[str] = []
|
|
66
|
+
skipped = 0
|
|
67
|
+
for path in sorted(folder.glob("*.md")):
|
|
68
|
+
try:
|
|
69
|
+
title, body = parse_markdown_item(path.read_text(), path.stem)
|
|
70
|
+
except OSError:
|
|
71
|
+
continue
|
|
72
|
+
fp = fingerprint(title, body)
|
|
73
|
+
if dstore.backlog_seen(fp):
|
|
74
|
+
continue
|
|
75
|
+
if len(filed) >= max_items:
|
|
76
|
+
skipped += 1
|
|
77
|
+
continue
|
|
78
|
+
try:
|
|
79
|
+
ref = source.file_backlog(title, body, run.run_id, trigger=trigger)
|
|
80
|
+
except Exception:
|
|
81
|
+
logger.warning(
|
|
82
|
+
"backlog: filing %r from run %s failed", title, run.run_id, exc_info=True
|
|
83
|
+
)
|
|
84
|
+
continue
|
|
85
|
+
dstore.backlog_record(fp, run.run_id, ref, now)
|
|
86
|
+
filed.append(ref)
|
|
87
|
+
if skipped:
|
|
88
|
+
logger.warning(
|
|
89
|
+
"backlog: run %s produced %d item(s) beyond the per-run cap of %d; deferred "
|
|
90
|
+
"(not fingerprint-recorded, so the next collection pass files them)",
|
|
91
|
+
run.run_id,
|
|
92
|
+
skipped,
|
|
93
|
+
max_items,
|
|
94
|
+
)
|
|
95
|
+
return filed
|