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.
Files changed (68) hide show
  1. {sbxloop-0.7.7 → sbxloop-0.7.8}/PKG-INFO +2 -1
  2. {sbxloop-0.7.7 → sbxloop-0.7.8}/pyproject.toml +1 -0
  3. 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
  4. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/_version.py +2 -2
  5. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/cli/app.py +110 -22
  6. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/cli/tui.py +20 -38
  7. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/config.py +29 -0
  8. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/backlog.py +26 -18
  9. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/control.py +69 -11
  10. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/discord.py +125 -35
  11. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/github.py +43 -11
  12. sbxloop-0.7.8/src/sbxloop/daemon/logsink.py +107 -0
  13. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/loop.py +476 -67
  14. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/sources.py +109 -20
  15. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/store.py +40 -2
  16. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/deliver.py +59 -4
  17. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/engine.py +67 -14
  18. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/phases.py +65 -0
  19. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/events.py +78 -4
  20. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/gc.py +9 -7
  21. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/gh/ops.py +16 -0
  22. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/gh/reporter.py +24 -7
  23. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/hostgit.py +8 -5
  24. sbxloop-0.7.8/src/sbxloop/log.py +197 -0
  25. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/bake.py +10 -8
  26. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/cli.py +33 -0
  27. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/pair.py +24 -10
  28. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/provision.py +72 -14
  29. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/secretstate.py +14 -13
  30. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/worker/client.py +180 -63
  31. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/worker/wheel.py +3 -3
  32. {sbxloop-0.7.7 → sbxloop-0.7.8}/.gitignore +0 -0
  33. {sbxloop-0.7.7 → sbxloop-0.7.8}/README.md +0 -0
  34. {sbxloop-0.7.7 → sbxloop-0.7.8}/hatch_build.py +0 -0
  35. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/__init__.py +0 -0
  36. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/cli/__init__.py +0 -0
  37. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/cli/doctor.py +0 -0
  38. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/cli/models.py +0 -0
  39. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/__init__.py +0 -0
  40. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/audits.py +0 -0
  41. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/discord_format.py +0 -0
  42. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/model.py +0 -0
  43. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/paths.py +0 -0
  44. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/daemon/postmortem.py +0 -0
  45. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/__init__.py +0 -0
  46. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/model.py +0 -0
  47. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/prompts/__init__.py +0 -0
  48. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/prompts/decompose.md +0 -0
  49. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/prompts/execute.md +0 -0
  50. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/prompts/plan.md +0 -0
  51. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
  52. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/prompts/steer.md +0 -0
  53. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/prompts/validate.md +0 -0
  54. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/engine/store.py +0 -0
  55. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/errors.py +0 -0
  56. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/gh/__init__.py +0 -0
  57. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/ids.py +0 -0
  58. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/policy.py +0 -0
  59. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/py.typed +0 -0
  60. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/__init__.py +0 -0
  61. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/conformance.py +0 -0
  62. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/models.py +0 -0
  63. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/parse.py +0 -0
  64. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/prune.py +0 -0
  65. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/sbx/sandbox.py +0 -0
  66. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/toolchains.py +0 -0
  67. {sbxloop-0.7.7 → sbxloop-0.7.8}/src/sbxloop/verifylint.py +0 -0
  68. {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.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'
@@ -25,6 +25,7 @@ dependencies = [
25
25
  # to the wheel metadata at build time.
26
26
  "sbxloop-worker",
27
27
  "gitpython>=3.1.59",
28
+ "structlog>=24.1",
28
29
  ]
29
30
 
30
31
  [project.optional-dependencies]
@@ -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.7'
22
- __version_tuple__ = version_tuple = (0, 7, 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
- logging.basicConfig(
1430
- level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
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
- console.print(f"[bold red]invalid daemon option:[/] {exc.errors()[0]['msg']}")
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
- console.print(
1472
- "[bold red]no work sources:[/] set [cyan]--repo owner/name[/] (or "
1473
- "[cyan]\\[github] repo[/]) to poll issues, and/or an inbox directory "
1474
- "([cyan]--inbox DIR[/] / [cyan]\\[daemon] inbox_dir[/])."
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
- console.print("[bold red]--backlog github needs a GitHub repository[/] (--repo).")
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
- 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."
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(plain_printer(console))
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
- console.print(f"[bold red]poll failed:[/] {exc}")
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
- console.print(f"[bold red]discord bridge:[/] {exc}")
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
- console.print("\n[bold yellow]daemon interrupted[/]")
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 event.data.get("task_id"):
479
- parts.append(f"[{event.data['task_id']}]")
480
- if event.data.get("agent"):
481
- parts.append(f"[{event.data['agent']}]")
482
- keys = (
483
- "state",
484
- "content",
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={event.data['disk_used_pct']}%"]
507
- if event.data.get("mem_used_pct") is not None:
508
- summary.append(f"mem={event.data['mem_used_pct']}%")
509
- if event.data.get("load1") is not None:
510
- summary.append(f"load={event.data['load1']}")
511
- if event.data.get("level"):
512
- summary.append(str(event.data["level"]))
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 event.data.get("args"):
515
- parts.append(_one_line(str(event.data["args"]), 120))
516
- if picked != "error" and event.data.get("error"):
517
- parts.append(_one_line(str(event.data["error"]), 160))
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
- logger = logging.getLogger(__name__)
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
- logger.warning(
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
- logger.warning(
141
- "backlog skipped for run %s: workspace not mounted (agent-filed backlog "
142
- "requires the mounted-workspace mode)",
143
- run.run_id,
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
- logger.warning(
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
- logger.warning(
173
- "backlog: run %s produced %d item(s) beyond the per-run cap of %d; deferred "
174
- "(not fingerprint-recorded, so the next collection pass files them)",
175
- run.run_id,
176
- skipped,
177
- max_items,
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