sbxloop 0.7.7__tar.gz → 0.7.8__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.7 → sbxloop-0.7.8}/PKG-INFO +2 -1
- {sbxloop-0.7.7 → sbxloop-0.7.8}/pyproject.toml +1 -0
- sbxloop-0.7.7/src/sbxloop/_vendor/sbxloop_worker-0.7.7-py3-none-any.whl → sbxloop-0.7.8/src/sbxloop/_vendor/sbxloop_worker-0.7.8-py3-none-any.whl +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/_version.py +2 -2
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/cli/app.py +110 -22
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/cli/tui.py +20 -38
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/config.py +29 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/backlog.py +26 -18
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/control.py +69 -11
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/discord.py +125 -35
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/github.py +43 -11
- sbxloop-0.7.8/src/sbxloop/daemon/logsink.py +107 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/loop.py +476 -67
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/sources.py +109 -20
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/store.py +40 -2
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/deliver.py +59 -4
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/engine.py +67 -14
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/phases.py +65 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/events.py +78 -4
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/gc.py +9 -7
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/gh/ops.py +16 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/gh/reporter.py +24 -7
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/hostgit.py +8 -5
- sbxloop-0.7.8/src/sbxloop/log.py +197 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/bake.py +10 -8
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/cli.py +33 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/pair.py +24 -10
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/provision.py +72 -14
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/secretstate.py +14 -13
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/worker/client.py +180 -63
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/worker/wheel.py +3 -3
- {sbxloop-0.7.7 → sbxloop-0.7.8}/.gitignore +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/README.md +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/hatch_build.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/__init__.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/cli/__init__.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/cli/doctor.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/cli/models.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/__init__.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/audits.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/discord_format.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/model.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/paths.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/postmortem.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/__init__.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/model.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/prompts/__init__.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/prompts/decompose.md +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/prompts/execute.md +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/prompts/plan.md +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/prompts/steer.md +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/prompts/validate.md +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/store.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/errors.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/gh/__init__.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/ids.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/policy.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/py.typed +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/__init__.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/conformance.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/models.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/parse.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/prune.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/sandbox.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/toolchains.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/verifylint.py +0 -0
- {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/worker/__init__.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.8
|
|
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
|
|
@@ -20,6 +20,7 @@ Requires-Dist: pydantic>=2.7
|
|
|
20
20
|
Requires-Dist: python-dotenv>=1.0
|
|
21
21
|
Requires-Dist: rich>=13.7
|
|
22
22
|
Requires-Dist: sbxloop-worker
|
|
23
|
+
Requires-Dist: structlog>=24.1
|
|
23
24
|
Requires-Dist: typer>=0.12
|
|
24
25
|
Provides-Extra: copilot
|
|
25
26
|
Requires-Dist: github-copilot-sdk>=1.0.8; extra == 'copilot'
|
|
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.8'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 7, 8)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -41,6 +41,7 @@ from sbxloop.engine.store import StateStore
|
|
|
41
41
|
from sbxloop.errors import SbxloopError
|
|
42
42
|
from sbxloop.events import Event, EventBus, HostEventTypes
|
|
43
43
|
from sbxloop.gc import DAY_S, format_bytes, prune_run_dirs
|
|
44
|
+
from sbxloop.log import configure_logging, get_logger
|
|
44
45
|
from sbxloop.sbx.bake import DEFAULT_TEMPLATE_REF, bake_template
|
|
45
46
|
from sbxloop.sbx.cli import SbxCLI
|
|
46
47
|
from sbxloop.sbx.models import SandboxRole
|
|
@@ -100,6 +101,11 @@ def _main_callback(
|
|
|
100
101
|
] = False,
|
|
101
102
|
) -> None:
|
|
102
103
|
"""sbxloop — agentic loops on Docker Sandboxes."""
|
|
104
|
+
# Library warnings (provisioning rollbacks, worker install fallbacks)
|
|
105
|
+
# render through the same pipeline as the daemon's instead of falling
|
|
106
|
+
# through to logging's bare last-resort handler. The daemon re-configures
|
|
107
|
+
# with its own level/format once its config is loaded.
|
|
108
|
+
configure_logging("WARNING")
|
|
103
109
|
# Every command sees ./.env (tokens + SBXLOOP_* settings); real
|
|
104
110
|
# environment variables always take precedence.
|
|
105
111
|
load_dotenv_file()
|
|
@@ -1409,6 +1415,17 @@ def daemon(
|
|
|
1409
1415
|
dry_run: Annotated[
|
|
1410
1416
|
bool, typer.Option("--dry-run", help="Poll and print candidates; claim and run nothing.")
|
|
1411
1417
|
] = False,
|
|
1418
|
+
log_level: Annotated[
|
|
1419
|
+
str | None,
|
|
1420
|
+
typer.Option(
|
|
1421
|
+
"--log-level",
|
|
1422
|
+
help="Daemon log level: DEBUG|INFO|WARNING|ERROR (also SBXLOOP_DAEMON__LOG_LEVEL).",
|
|
1423
|
+
),
|
|
1424
|
+
] = None,
|
|
1425
|
+
log_format: Annotated[
|
|
1426
|
+
str | None,
|
|
1427
|
+
typer.Option("--log-format", help="Daemon log rendering: console|json."),
|
|
1428
|
+
] = None,
|
|
1412
1429
|
) -> None:
|
|
1413
1430
|
"""Run the always-on outer loop: discover work (labeled GitHub issues,
|
|
1414
1431
|
inbox files), run each item through the inner loop, report back, and
|
|
@@ -1417,18 +1434,16 @@ def daemon(
|
|
|
1417
1434
|
daemon instead."""
|
|
1418
1435
|
if ctx.invoked_subcommand is not None:
|
|
1419
1436
|
return
|
|
1420
|
-
import logging
|
|
1421
|
-
|
|
1422
1437
|
from sbxloop.daemon.control import ControlServer
|
|
1423
1438
|
from sbxloop.daemon.discord import DiscordBridge
|
|
1424
1439
|
from sbxloop.daemon.github import DaemonGithub
|
|
1440
|
+
from sbxloop.daemon.logsink import event_log_subscriber
|
|
1425
1441
|
from sbxloop.daemon.loop import DaemonLoop
|
|
1426
1442
|
from sbxloop.daemon.paths import resolve_state_dir
|
|
1427
1443
|
from sbxloop.daemon.sources import GitHubIssueSource, GitHubLabels, InboxSource, WorkSource
|
|
1428
1444
|
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
)
|
|
1445
|
+
log = get_logger("sbxloop.daemon")
|
|
1446
|
+
started_at = time.monotonic()
|
|
1432
1447
|
config, config_sources = load_config_with_sources()
|
|
1433
1448
|
# The daemon's state lives at an absolute path outside the workspace
|
|
1434
1449
|
# (#255): a relative `.sbxloop` inside the checkout it works on would
|
|
@@ -1444,6 +1459,8 @@ def daemon(
|
|
|
1444
1459
|
("backlog", backlog),
|
|
1445
1460
|
("max_runs_per_day", max_runs_per_day),
|
|
1446
1461
|
("poll_interval_s", poll_interval),
|
|
1462
|
+
("log_level", log_level),
|
|
1463
|
+
("log_format", log_format),
|
|
1447
1464
|
)
|
|
1448
1465
|
if v is not None
|
|
1449
1466
|
}
|
|
@@ -1462,38 +1479,39 @@ def daemon(
|
|
|
1462
1479
|
)
|
|
1463
1480
|
config = config.model_copy(update={"discord": discord_cfg})
|
|
1464
1481
|
except ValidationError as exc:
|
|
1465
|
-
|
|
1482
|
+
# Before the pipeline is configured with the daemon's own settings:
|
|
1483
|
+
# the WARNING-level default from the app callback carries this.
|
|
1484
|
+
log.error("daemon.invalid_option", error=exc.errors()[0]["msg"])
|
|
1466
1485
|
raise typer.Exit(2) from exc
|
|
1486
|
+
configure_logging(config.daemon.log_level, fmt=config.daemon.log_format)
|
|
1467
1487
|
|
|
1468
1488
|
github_active = config.github.enabled
|
|
1469
1489
|
inbox_active = bool(config.daemon.inbox_dir)
|
|
1470
1490
|
if not github_active and not inbox_active:
|
|
1471
|
-
|
|
1472
|
-
"
|
|
1473
|
-
"[
|
|
1474
|
-
"(
|
|
1491
|
+
log.error(
|
|
1492
|
+
"daemon.no_work_sources",
|
|
1493
|
+
hint="set --repo owner/name (or [github] repo) to poll issues, and/or an "
|
|
1494
|
+
"inbox directory (--inbox DIR / [daemon] inbox_dir)",
|
|
1475
1495
|
)
|
|
1476
1496
|
raise typer.Exit(2)
|
|
1477
1497
|
if config.daemon.backlog == "github" and not github_active:
|
|
1478
|
-
|
|
1498
|
+
log.error("daemon.backlog_needs_github", hint="--backlog github needs --repo owner/name")
|
|
1479
1499
|
raise typer.Exit(2)
|
|
1480
1500
|
if github_active and config.daemon.backlog != "github":
|
|
1481
1501
|
# The audit lane is always on when issues are a source; its
|
|
1482
1502
|
# findings only have somewhere to go with backlog = "github".
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1503
|
+
log.warning(
|
|
1504
|
+
"daemon.audit_findings_unfiled",
|
|
1505
|
+
audit_label=config.daemon.audit_label,
|
|
1506
|
+
backlog=config.daemon.backlog,
|
|
1507
|
+
hint="audit runs complete but file nothing without --backlog github",
|
|
1487
1508
|
)
|
|
1488
1509
|
|
|
1489
|
-
# Say where the state went: with the anchored default, `sbxloop status`
|
|
1490
|
-
# in the runner dir shows nothing unless SBXLOOP_STATE_DIR points here.
|
|
1491
|
-
console.print(f"state dir: [cyan]{config.state_dir}[/] ({state_choice.reason})")
|
|
1492
1510
|
store = _store(config)
|
|
1493
1511
|
dstore = DaemonStore(config.state_dir / "state.db")
|
|
1494
1512
|
sbx = SbxCLI(app_name=config.app_name or None)
|
|
1495
1513
|
bus = EventBus()
|
|
1496
|
-
bus.subscribe(
|
|
1514
|
+
bus.subscribe(event_log_subscriber)
|
|
1497
1515
|
sources: list[WorkSource] = []
|
|
1498
1516
|
github: DaemonGithub | None = None
|
|
1499
1517
|
if inbox_active:
|
|
@@ -1521,17 +1539,64 @@ def daemon(
|
|
|
1521
1539
|
)
|
|
1522
1540
|
)
|
|
1523
1541
|
|
|
1542
|
+
# One line an operator can read back from the journal to know exactly
|
|
1543
|
+
# what this daemon is: where its state went (with the anchored default,
|
|
1544
|
+
# `sbxloop status` in the runner dir shows nothing unless
|
|
1545
|
+
# SBXLOOP_STATE_DIR points here), what it polls, and every guardrail.
|
|
1546
|
+
log.info(
|
|
1547
|
+
"daemon.starting",
|
|
1548
|
+
version=sbxloop.__version__,
|
|
1549
|
+
pid=os.getpid(),
|
|
1550
|
+
state_dir=str(config.state_dir),
|
|
1551
|
+
state_dir_reason=state_choice.reason,
|
|
1552
|
+
sources=[s.name for s in sources],
|
|
1553
|
+
repo=config.github.repo,
|
|
1554
|
+
trigger_label=config.daemon.trigger_label if github_active else None,
|
|
1555
|
+
inbox_dir=config.daemon.inbox_dir or None,
|
|
1556
|
+
poll_interval_s=config.daemon.poll_interval_s,
|
|
1557
|
+
max_runs_per_day=config.daemon.max_runs_per_day,
|
|
1558
|
+
max_attempts_per_item=config.daemon.max_attempts_per_item,
|
|
1559
|
+
max_resumes_per_item=config.daemon.max_resumes_per_item,
|
|
1560
|
+
retry_backoff_s=config.daemon.retry_backoff_s,
|
|
1561
|
+
max_consecutive_failures=config.daemon.max_consecutive_failures,
|
|
1562
|
+
breaker_cooldown_s=config.daemon.breaker_cooldown_s,
|
|
1563
|
+
workspace_isolation=config.daemon.workspace_isolation,
|
|
1564
|
+
refresh_workspace=config.daemon.refresh_workspace,
|
|
1565
|
+
backlog=config.daemon.backlog,
|
|
1566
|
+
audits=config.daemon.audits,
|
|
1567
|
+
postmortems=config.daemon.postmortems,
|
|
1568
|
+
review_deliveries=config.daemon.review_deliveries,
|
|
1569
|
+
discord=("on" if config.discord.enabled else "off"),
|
|
1570
|
+
discord_channel=config.discord.channel_id if config.discord.enabled else None,
|
|
1571
|
+
log_level=config.daemon.log_level,
|
|
1572
|
+
log_format=config.daemon.log_format,
|
|
1573
|
+
once=once,
|
|
1574
|
+
dry_run=dry_run,
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1524
1577
|
if dry_run:
|
|
1525
1578
|
code = 0
|
|
1526
1579
|
try:
|
|
1527
1580
|
for source in sources:
|
|
1581
|
+
found = 0
|
|
1528
1582
|
for item in source.poll():
|
|
1583
|
+
found += 1
|
|
1584
|
+
# The listing IS this command's output: stdout, so it
|
|
1585
|
+
# pipes and greps; the log keeps the structured record.
|
|
1529
1586
|
console.print(
|
|
1530
1587
|
f"[cyan]{item.item_id}[/] {item.title}"
|
|
1531
1588
|
+ (f" {item.url}" if item.url else "")
|
|
1532
1589
|
)
|
|
1590
|
+
log.debug(
|
|
1591
|
+
"daemon.dry_run_candidate",
|
|
1592
|
+
source=source.name,
|
|
1593
|
+
item=item.item_id,
|
|
1594
|
+
title=item.title,
|
|
1595
|
+
url=item.url or None,
|
|
1596
|
+
)
|
|
1597
|
+
log.info("daemon.dry_run_polled", source=source.name, candidates=found)
|
|
1533
1598
|
except SbxloopError as exc:
|
|
1534
|
-
|
|
1599
|
+
log.error("daemon.poll_failed", error=str(exc), exc_info=True)
|
|
1535
1600
|
code = 1
|
|
1536
1601
|
finally:
|
|
1537
1602
|
if github is not None:
|
|
@@ -1545,13 +1610,14 @@ def daemon(
|
|
|
1545
1610
|
bridge = DiscordBridge(config, dstore, loop_ref=loop)
|
|
1546
1611
|
bridge.start()
|
|
1547
1612
|
except SbxloopError as exc:
|
|
1548
|
-
|
|
1613
|
+
log.error("discord.bridge_failed", error=str(exc), exc_info=True)
|
|
1549
1614
|
raise typer.Exit(2) from exc
|
|
1550
1615
|
loop.frontend = bridge
|
|
1551
1616
|
|
|
1552
1617
|
ctl = ControlServer(loop, config.state_dir)
|
|
1553
1618
|
cleanup_registry.install_handlers()
|
|
1554
1619
|
cleanup_registry.set_quiesce(loop.quiesce)
|
|
1620
|
+
stop_reason = "finished"
|
|
1555
1621
|
try:
|
|
1556
1622
|
loop.recover()
|
|
1557
1623
|
# Only now: an `abandon`/`requeue` served while recover() is still
|
|
@@ -1561,19 +1627,41 @@ def daemon(
|
|
|
1561
1627
|
ctl.start()
|
|
1562
1628
|
if once:
|
|
1563
1629
|
result = loop.tick()
|
|
1630
|
+
# --once is a smoke/cron probe: its one-line verdict stays on
|
|
1631
|
+
# stdout for the human or script that invoked it.
|
|
1564
1632
|
console.print(f"tick: {result}")
|
|
1633
|
+
log.info(
|
|
1634
|
+
"daemon.tick",
|
|
1635
|
+
discovered=result.discovered,
|
|
1636
|
+
dispatched=result.dispatched,
|
|
1637
|
+
outcome=result.outcome,
|
|
1638
|
+
idle=result.idle_kind,
|
|
1639
|
+
idle_detail=result.idle_detail,
|
|
1640
|
+
)
|
|
1565
1641
|
else:
|
|
1566
1642
|
loop.run_forever()
|
|
1567
1643
|
except KeyboardInterrupt:
|
|
1568
|
-
|
|
1644
|
+
stop_reason = "interrupted"
|
|
1645
|
+
log.warning("daemon.interrupted", hint="KeyboardInterrupt; shutting down")
|
|
1646
|
+
except BaseException as exc:
|
|
1647
|
+
stop_reason = type(exc).__name__
|
|
1648
|
+
raise
|
|
1569
1649
|
finally:
|
|
1570
1650
|
cleanup_registry.set_quiesce(None)
|
|
1651
|
+
log.debug("daemon.shutdown", step="control server")
|
|
1571
1652
|
ctl.close()
|
|
1572
1653
|
if bridge is not None:
|
|
1654
|
+
log.debug("daemon.shutdown", step="discord bridge")
|
|
1573
1655
|
bridge.close()
|
|
1574
1656
|
if github is not None:
|
|
1657
|
+
log.debug("daemon.shutdown", step="github sandbox")
|
|
1575
1658
|
github.close()
|
|
1576
1659
|
dstore.close()
|
|
1660
|
+
log.info(
|
|
1661
|
+
"daemon.stopped",
|
|
1662
|
+
reason=stop_reason,
|
|
1663
|
+
uptime_s=round(time.monotonic() - started_at, 1),
|
|
1664
|
+
)
|
|
1577
1665
|
|
|
1578
1666
|
|
|
1579
1667
|
def _daemon_state_dir() -> Path:
|
|
@@ -35,7 +35,7 @@ from rich.panel import Panel
|
|
|
35
35
|
from rich.table import Table
|
|
36
36
|
from rich.text import Text
|
|
37
37
|
|
|
38
|
-
from sbxloop.events import Event, HostEventTypes
|
|
38
|
+
from sbxloop.events import Event, HostEventTypes, summarize_event
|
|
39
39
|
from sbxloop_worker.protocol import EventTypes
|
|
40
40
|
|
|
41
41
|
TASK_STATE_STYLES = {
|
|
@@ -474,47 +474,29 @@ class Dashboard:
|
|
|
474
474
|
|
|
475
475
|
def format_event(event: Event) -> str:
|
|
476
476
|
"""Dense single-line form (used by `sbxloop logs` and lifecycle lines)."""
|
|
477
|
+
fields = summarize_event(event)
|
|
477
478
|
parts = [_stamp(event), event.type]
|
|
478
|
-
if
|
|
479
|
-
parts.append(f"[{
|
|
480
|
-
if
|
|
481
|
-
parts.append(f"[{
|
|
482
|
-
|
|
483
|
-
"
|
|
484
|
-
|
|
485
|
-
"text",
|
|
486
|
-
"reply",
|
|
487
|
-
"tool",
|
|
488
|
-
"op",
|
|
489
|
-
"line",
|
|
490
|
-
"message",
|
|
491
|
-
"outcome",
|
|
492
|
-
"error",
|
|
493
|
-
"url",
|
|
494
|
-
"path",
|
|
495
|
-
)
|
|
496
|
-
picked = ""
|
|
497
|
-
for key in keys:
|
|
498
|
-
if event.data.get(key):
|
|
499
|
-
picked = key
|
|
500
|
-
value = str(event.data[key]).replace("\n", " ")
|
|
501
|
-
parts.append(value[:160])
|
|
502
|
-
break
|
|
503
|
-
if event.data.get("disk_used_pct") is not None:
|
|
479
|
+
if "task" in fields:
|
|
480
|
+
parts.append(f"[{fields['task']}]")
|
|
481
|
+
if "agent" in fields:
|
|
482
|
+
parts.append(f"[{fields['agent']}]")
|
|
483
|
+
if "summary" in fields:
|
|
484
|
+
parts.append(fields["summary"])
|
|
485
|
+
if "disk" in fields:
|
|
504
486
|
# Resource samples: make `sbxloop logs --type-prefix sandbox.resources`
|
|
505
487
|
# answer "what did disk look like before the failure" at a glance.
|
|
506
|
-
summary = [f"disk={
|
|
507
|
-
if
|
|
508
|
-
summary.append(f"mem={
|
|
509
|
-
if
|
|
510
|
-
summary.append(f"load={
|
|
511
|
-
if
|
|
512
|
-
summary.append(str(
|
|
488
|
+
summary = [f"disk={fields['disk']}"]
|
|
489
|
+
if "mem" in fields:
|
|
490
|
+
summary.append(f"mem={fields['mem']}")
|
|
491
|
+
if "load" in fields:
|
|
492
|
+
summary.append(f"load={fields['load']}")
|
|
493
|
+
if "resource_level" in fields:
|
|
494
|
+
summary.append(str(fields["resource_level"]))
|
|
513
495
|
parts.append(" ".join(summary))
|
|
514
|
-
if
|
|
515
|
-
parts.append(
|
|
516
|
-
if
|
|
517
|
-
parts.append(
|
|
496
|
+
if "args" in fields:
|
|
497
|
+
parts.append(fields["args"])
|
|
498
|
+
if "error" in fields:
|
|
499
|
+
parts.append(fields["error"])
|
|
518
500
|
return " ".join(parts)
|
|
519
501
|
|
|
520
502
|
|
|
@@ -37,8 +37,11 @@ from pydantic import (
|
|
|
37
37
|
)
|
|
38
38
|
|
|
39
39
|
from sbxloop.errors import ConfigError
|
|
40
|
+
from sbxloop.log import LogFormat, LogLevel, get_logger
|
|
40
41
|
from sbxloop.toolchains import DEFAULT_LANGUAGES, normalize_language, supported_languages
|
|
41
42
|
|
|
43
|
+
log = get_logger(__name__)
|
|
44
|
+
|
|
42
45
|
ENV_PREFIX = "SBXLOOP_"
|
|
43
46
|
|
|
44
47
|
# SBXLOOP_-prefixed variables consumed by the *worker process* rather than
|
|
@@ -409,6 +412,23 @@ class DaemonConfig(_ConfigModel):
|
|
|
409
412
|
# artifacts). Swept on daemon start and daily; 0 disables. The SQLite
|
|
410
413
|
# rows are never removed. See sbxloop.gc for what is exempt.
|
|
411
414
|
prune_runs_after_days: float = Field(default=14.0, ge=0)
|
|
415
|
+
# The daemon's own log stream (stderr → journald under systemd). INFO is
|
|
416
|
+
# the lifecycle tier: startup summary, claims, run dispatch/finish, task
|
|
417
|
+
# and phase transitions, operator commands; DEBUG adds every tool call,
|
|
418
|
+
# sbx invocation and poll. ``json`` renders one object per line for log
|
|
419
|
+
# shippers; ``console`` is key=value for humans and journalctl.
|
|
420
|
+
log_level: LogLevel = "INFO"
|
|
421
|
+
log_format: LogFormat = "console"
|
|
422
|
+
|
|
423
|
+
@field_validator("log_level", mode="before")
|
|
424
|
+
@classmethod
|
|
425
|
+
def _upper_level(cls, value: Any) -> Any:
|
|
426
|
+
return value.upper() if isinstance(value, str) else value
|
|
427
|
+
|
|
428
|
+
@field_validator("log_format", mode="before")
|
|
429
|
+
@classmethod
|
|
430
|
+
def _lower_format(cls, value: Any) -> Any:
|
|
431
|
+
return value.lower() if isinstance(value, str) else value
|
|
412
432
|
|
|
413
433
|
@model_validator(mode="after")
|
|
414
434
|
def _check(self) -> DaemonConfig:
|
|
@@ -708,6 +728,15 @@ def load_config_with_sources(
|
|
|
708
728
|
|
|
709
729
|
for dotted in _flatten(config.model_dump()):
|
|
710
730
|
sources.setdefault(dotted, "default")
|
|
731
|
+
# Which layer set which key — never the values (tokens live in env).
|
|
732
|
+
overridden = {k: v for k, v in sources.items() if v != "default"}
|
|
733
|
+
log.debug(
|
|
734
|
+
"config.loaded",
|
|
735
|
+
cwd=str(cwd),
|
|
736
|
+
state_dir=str(config.state_dir),
|
|
737
|
+
layers={name: len(_flatten(layer)) for name, layer in layers},
|
|
738
|
+
overrides=overridden,
|
|
739
|
+
)
|
|
711
740
|
return config, sources
|
|
712
741
|
|
|
713
742
|
|
|
@@ -18,15 +18,15 @@ flood the daemon.
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import hashlib
|
|
21
|
-
import logging
|
|
22
21
|
from pathlib import Path
|
|
23
22
|
from typing import NamedTuple
|
|
24
23
|
|
|
25
24
|
from sbxloop.daemon.sources import WorkSource, parse_markdown_item
|
|
26
25
|
from sbxloop.daemon.store import DaemonStore
|
|
27
26
|
from sbxloop.engine.model import RunRecord
|
|
27
|
+
from sbxloop.log import get_logger
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
log = get_logger(__name__)
|
|
30
30
|
|
|
31
31
|
BACKLOG_SUBDIR = Path(".sbxloop") / "backlog"
|
|
32
32
|
|
|
@@ -87,6 +87,7 @@ def collect_tool_findings(
|
|
|
87
87
|
try:
|
|
88
88
|
title, body = parse_markdown_item(path.read_text(), path.stem)
|
|
89
89
|
except OSError:
|
|
90
|
+
log.warning("backlog.finding_unreadable", run=run.run_id, path=str(path), exc_info=True)
|
|
90
91
|
continue
|
|
91
92
|
if filer is None:
|
|
92
93
|
unfiled.append(title)
|
|
@@ -100,9 +101,7 @@ def collect_tool_findings(
|
|
|
100
101
|
try:
|
|
101
102
|
ref = filer(title, body, run.run_id)
|
|
102
103
|
except Exception:
|
|
103
|
-
|
|
104
|
-
"tool finding %r from run %s could not be filed", title, run.run_id, exc_info=True
|
|
105
|
-
)
|
|
104
|
+
log.warning("backlog.tool_finding_failed", run=run.run_id, title=title, exc_info=True)
|
|
106
105
|
unfiled.append(title)
|
|
107
106
|
continue
|
|
108
107
|
if ref is None:
|
|
@@ -137,10 +136,10 @@ def collect_backlog(
|
|
|
137
136
|
) -> list[str]:
|
|
138
137
|
"""File the run's backlog items via ``source``; returns the refs filed."""
|
|
139
138
|
if not run.mounted or run.workspace is None:
|
|
140
|
-
|
|
141
|
-
"backlog
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
log.warning(
|
|
140
|
+
"backlog.skipped",
|
|
141
|
+
run=run.run_id,
|
|
142
|
+
reason="workspace not mounted; agent-filed backlog needs the mounted-workspace mode",
|
|
144
143
|
)
|
|
145
144
|
return []
|
|
146
145
|
folder = run.workspace / BACKLOG_SUBDIR
|
|
@@ -148,13 +147,16 @@ def collect_backlog(
|
|
|
148
147
|
return []
|
|
149
148
|
filed: list[str] = []
|
|
150
149
|
skipped = 0
|
|
150
|
+
seen = 0
|
|
151
151
|
for path in sorted(folder.glob("*.md")):
|
|
152
152
|
try:
|
|
153
153
|
title, body = parse_markdown_item(path.read_text(), path.stem)
|
|
154
154
|
except OSError:
|
|
155
|
+
log.warning("backlog.finding_unreadable", run=run.run_id, path=str(path), exc_info=True)
|
|
155
156
|
continue
|
|
156
157
|
fp = fingerprint(title, body)
|
|
157
158
|
if dstore.backlog_seen(fp):
|
|
159
|
+
seen += 1
|
|
158
160
|
continue
|
|
159
161
|
if len(filed) >= max_items:
|
|
160
162
|
skipped += 1
|
|
@@ -162,18 +164,24 @@ def collect_backlog(
|
|
|
162
164
|
try:
|
|
163
165
|
ref = source.file_backlog(title, body, run.run_id, trigger=trigger)
|
|
164
166
|
except Exception:
|
|
165
|
-
|
|
166
|
-
"backlog: filing %r from run %s failed", title, run.run_id, exc_info=True
|
|
167
|
-
)
|
|
167
|
+
log.warning("backlog.file_failed", run=run.run_id, title=title, exc_info=True)
|
|
168
168
|
continue
|
|
169
169
|
dstore.backlog_record(fp, run.run_id, ref, now)
|
|
170
170
|
filed.append(ref)
|
|
171
|
+
log.debug(
|
|
172
|
+
"backlog.collected",
|
|
173
|
+
run=run.run_id,
|
|
174
|
+
filed=len(filed),
|
|
175
|
+
already_seen=seen,
|
|
176
|
+
deferred=skipped,
|
|
177
|
+
trigger=trigger,
|
|
178
|
+
)
|
|
171
179
|
if skipped:
|
|
172
|
-
|
|
173
|
-
"backlog
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
180
|
+
log.warning(
|
|
181
|
+
"backlog.cap_deferred",
|
|
182
|
+
run=run.run_id,
|
|
183
|
+
deferred=skipped,
|
|
184
|
+
cap=max_items,
|
|
185
|
+
hint="not fingerprint-recorded, so the next collection pass files them",
|
|
178
186
|
)
|
|
179
187
|
return filed
|