agent-core-hatchery 0.8.2__py3-none-any.whl
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.
- agent_core_hatchery/__init__.py +1 -0
- agent_core_hatchery/channels/__init__.py +7 -0
- agent_core_hatchery/channels/discord.py +43 -0
- agent_core_hatchery/channels/github_backup.py +65 -0
- agent_core_hatchery/channels/webcam.py +26 -0
- agent_core_hatchery/cli.py +97 -0
- agent_core_hatchery/config.py +117 -0
- agent_core_hatchery/daemon_config.py +82 -0
- agent_core_hatchery/daemon_probe.py +140 -0
- agent_core_hatchery/elder_letters.py +65 -0
- agent_core_hatchery/file_classes.py +111 -0
- agent_core_hatchery/hatcher.py +307 -0
- agent_core_hatchery/renderer.py +58 -0
- agent_core_hatchery/report.py +83 -0
- agent_core_hatchery/snapshot_elders.py +53 -0
- agent_core_hatchery/validators.py +56 -0
- agent_core_hatchery/wizard.py +188 -0
- agent_core_hatchery-0.8.2.dist-info/METADATA +13 -0
- agent_core_hatchery-0.8.2.dist-info/RECORD +21 -0
- agent_core_hatchery-0.8.2.dist-info/WHEEL +4 -0
- agent_core_hatchery-0.8.2.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""agent-core-hatchery — bootstrap system for hatching new beings."""
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Channel scaffolding (Discord, webcam, GitHub backup).
|
|
2
|
+
|
|
3
|
+
Each module exports a `scaffold_<name>(config)` function that:
|
|
4
|
+
- Returns None if the channel is disabled.
|
|
5
|
+
- Performs side effects (write env file, write hook script) and returns
|
|
6
|
+
a dict (the endpoint or job block) to be appended to daemon fragments.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Discord channel scaffolding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from agent_core_hatchery.config import HatchConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def scaffold_discord(config: HatchConfig) -> dict[str, Any] | None:
|
|
13
|
+
if not config.channels.discord.enabled:
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
daemon_dir = config.resolved_daemon_config_dir()
|
|
17
|
+
daemon_dir.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
env_file = daemon_dir / f"discord-{config.being_name_lower}.env"
|
|
19
|
+
env_file.write_text(
|
|
20
|
+
f"{config.discord_token_env}={config.channels.discord.resolved_token}\n",
|
|
21
|
+
encoding="utf-8",
|
|
22
|
+
)
|
|
23
|
+
if os.name != "nt":
|
|
24
|
+
env_file.chmod(0o600)
|
|
25
|
+
|
|
26
|
+
block: dict[str, Any] = {
|
|
27
|
+
"type": "builtin.discord",
|
|
28
|
+
"name": f"discord-{config.being_name_lower}",
|
|
29
|
+
"description": f"Discord adapter for {config.being_name}.",
|
|
30
|
+
"params": {
|
|
31
|
+
"target": config.endpoint_name,
|
|
32
|
+
"token_env": config.discord_token_env,
|
|
33
|
+
"env_file": str(env_file),
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
if config.channels.discord.channel_allowlist:
|
|
37
|
+
access_file = daemon_dir / f"discord-{config.being_name_lower}-access.json"
|
|
38
|
+
access_file.write_text(
|
|
39
|
+
json.dumps({"channels": config.channels.discord.channel_allowlist}, indent=2),
|
|
40
|
+
encoding="utf-8",
|
|
41
|
+
)
|
|
42
|
+
block["params"]["access_config_path"] = str(access_file)
|
|
43
|
+
return block
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""GitHub backup scaffolding (CW-4 — opt-in)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from agent_core_hatchery.config import HatchConfig
|
|
9
|
+
|
|
10
|
+
_HOOK_TEMPLATE = """#!/usr/bin/env bash
|
|
11
|
+
# Generated by agent-core-hatchery for {being_name}.
|
|
12
|
+
# Backup the vault's Memory/ to GitHub. Invoked by the
|
|
13
|
+
# {being_name_lower}-github_backup scheduler job.
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
|
|
16
|
+
VAULT_ROOT="{vault_root}"
|
|
17
|
+
REPO_URL="{repo_url}"
|
|
18
|
+
|
|
19
|
+
cd "$VAULT_ROOT"
|
|
20
|
+
if [ ! -d Memory/.git ]; then
|
|
21
|
+
cd Memory
|
|
22
|
+
git init
|
|
23
|
+
git remote add origin "$REPO_URL"
|
|
24
|
+
cd ..
|
|
25
|
+
fi
|
|
26
|
+
cd Memory
|
|
27
|
+
git add -A
|
|
28
|
+
git commit -m "backup $(date -Iseconds)" || true # ok if nothing to commit
|
|
29
|
+
git push -u origin HEAD || true # warn-only on first push
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def scaffold_github_backup(config: HatchConfig) -> dict[str, Any] | None:
|
|
34
|
+
if not config.channels.github_backup.enabled:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
hook_dir = config.resolved_vault_root() / "hooks"
|
|
38
|
+
hook_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
hook_path = hook_dir / "backup-to-github.sh"
|
|
40
|
+
hook_path.write_text(
|
|
41
|
+
_HOOK_TEMPLATE.format(
|
|
42
|
+
being_name=config.being_name,
|
|
43
|
+
being_name_lower=config.being_name_lower,
|
|
44
|
+
vault_root=str(config.resolved_vault_root()).replace("\\", "/"),
|
|
45
|
+
repo_url=config.channels.github_backup.repo_url,
|
|
46
|
+
),
|
|
47
|
+
encoding="utf-8",
|
|
48
|
+
)
|
|
49
|
+
if os.name != "nt":
|
|
50
|
+
hook_path.chmod(0o755)
|
|
51
|
+
|
|
52
|
+
job_name = f"{config.being_name_lower}-github_backup"
|
|
53
|
+
return {
|
|
54
|
+
job_name: {
|
|
55
|
+
"trigger": "cron",
|
|
56
|
+
"timezone": "America/New_York",
|
|
57
|
+
"schedule": {"hour": 4, "minute": 30},
|
|
58
|
+
"target": config.endpoint_name,
|
|
59
|
+
"envelope_kind": "Event",
|
|
60
|
+
"payload": {
|
|
61
|
+
"type": "GitHubBackup",
|
|
62
|
+
"data": {"hook_path": str(hook_path)},
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Webcam channel scaffolding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agent_core_hatchery.config import HatchConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def scaffold_webcam(config: HatchConfig) -> dict[str, Any] | None:
|
|
11
|
+
if not config.channels.webcam.enabled:
|
|
12
|
+
return None
|
|
13
|
+
return {
|
|
14
|
+
"type": "builtin.webcam",
|
|
15
|
+
"name": f"webcam-{config.being_name_lower}",
|
|
16
|
+
"description": f"{config.being_name}'s webcam endpoint.",
|
|
17
|
+
"params": {
|
|
18
|
+
"enabled": True,
|
|
19
|
+
"captures_root": f"~/.agent-core/webcam/{config.being_name_lower}",
|
|
20
|
+
"audit_log_path": f"~/.agent-core/webcam/{config.being_name_lower}/audit.jsonl",
|
|
21
|
+
"default_camera_index": 0,
|
|
22
|
+
"default_resolution": [1280, 720],
|
|
23
|
+
"max_resolution": [3840, 2160],
|
|
24
|
+
"capture_timeout_seconds": 3.0,
|
|
25
|
+
},
|
|
26
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""hatch-being CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from agent_core_hatchery.config import HatchConfig
|
|
11
|
+
from agent_core_hatchery.daemon_probe import reload_and_probe
|
|
12
|
+
from agent_core_hatchery.hatcher import Hatcher, VaultExistsError
|
|
13
|
+
from agent_core_hatchery.report import write_hatching_report
|
|
14
|
+
from agent_core_hatchery.wizard import offer_letter_authoring, run_wizard
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="hatch-being",
|
|
18
|
+
help="Hatch a new agent-core being. Run with no flags for interactive TUI.",
|
|
19
|
+
no_args_is_help=False,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.callback(invoke_without_command=True)
|
|
24
|
+
def hatch_being(
|
|
25
|
+
config: Path | None = typer.Option( # noqa: B008
|
|
26
|
+
None, "--config",
|
|
27
|
+
help="Non-interactive: load HatchConfig from YAML.",
|
|
28
|
+
),
|
|
29
|
+
vault_root: Path | None = typer.Option( # noqa: B008
|
|
30
|
+
None, "--vault-root", "--root",
|
|
31
|
+
help="Override the resolved vault root. Default: $HOME.",
|
|
32
|
+
),
|
|
33
|
+
daemon_config_dir: Path | None = typer.Option( # noqa: B008
|
|
34
|
+
None, "--daemon-config-dir",
|
|
35
|
+
help="Override the daemon's config directory. Default: ~/.agent-core/.",
|
|
36
|
+
),
|
|
37
|
+
init_missing: bool = typer.Option(
|
|
38
|
+
False, "--init-missing",
|
|
39
|
+
help="Top-up an existing vault with newly-added scaffolding files.",
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
if config is not None:
|
|
43
|
+
raw = yaml.safe_load(config.read_text(encoding="utf-8")) or {}
|
|
44
|
+
cfg = HatchConfig(**raw)
|
|
45
|
+
interactive = False
|
|
46
|
+
else:
|
|
47
|
+
cfg = run_wizard()
|
|
48
|
+
interactive = True
|
|
49
|
+
|
|
50
|
+
if vault_root is not None:
|
|
51
|
+
cfg = cfg.model_copy(update={"vault_root": str(vault_root)})
|
|
52
|
+
if daemon_config_dir is not None:
|
|
53
|
+
cfg = cfg.model_copy(update={"daemon_config_dir": str(daemon_config_dir)})
|
|
54
|
+
if init_missing:
|
|
55
|
+
cfg = cfg.model_copy(update={"init_missing": True})
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
result = Hatcher(cfg).hatch()
|
|
59
|
+
except VaultExistsError as exc:
|
|
60
|
+
typer.secho(str(exc), fg=typer.colors.RED)
|
|
61
|
+
raise typer.Exit(code=1) from None
|
|
62
|
+
|
|
63
|
+
letter_authored = False
|
|
64
|
+
if interactive:
|
|
65
|
+
letter_authored = offer_letter_authoring(cfg)
|
|
66
|
+
|
|
67
|
+
if not cfg.init_missing:
|
|
68
|
+
daemon_check_status = reload_and_probe(cfg)
|
|
69
|
+
else:
|
|
70
|
+
daemon_check_status = "skipped"
|
|
71
|
+
|
|
72
|
+
report_path = write_hatching_report(
|
|
73
|
+
cfg, result,
|
|
74
|
+
letter_authored=letter_authored,
|
|
75
|
+
daemon_check_status=daemon_check_status,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
typer.echo(f"\nHatched at {result.vault_root}")
|
|
79
|
+
typer.echo(f"Report: {report_path}")
|
|
80
|
+
typer.echo("🐣")
|
|
81
|
+
|
|
82
|
+
if daemon_check_status == "start_failed":
|
|
83
|
+
typer.secho(
|
|
84
|
+
"✗ Daemon FAILED to restart after hatch — the fleet may be OFFLINE. "
|
|
85
|
+
"Run `agent-core daemon start` now.",
|
|
86
|
+
fg=typer.colors.RED,
|
|
87
|
+
err=True,
|
|
88
|
+
)
|
|
89
|
+
raise typer.Exit(code=1)
|
|
90
|
+
|
|
91
|
+
if daemon_check_status == "reachable_but_missing":
|
|
92
|
+
typer.secho(
|
|
93
|
+
"⚠ Daemon probe: endpoint not registered after reload. See HATCHING-REPORT.md.",
|
|
94
|
+
fg=typer.colors.RED,
|
|
95
|
+
err=True,
|
|
96
|
+
)
|
|
97
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""HatchConfig — pydantic model for the hatcher's input contract.
|
|
2
|
+
|
|
3
|
+
Both the TUI wizard and `--config <yaml>` mode produce instances of this
|
|
4
|
+
model. The model is the single source of truth for what inputs the
|
|
5
|
+
hatcher needs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from datetime import date
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator
|
|
16
|
+
|
|
17
|
+
_ENV_VAR_PATTERN = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)\}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _substitute_env_vars(value: str) -> str:
|
|
21
|
+
"""Replace ${VAR} occurrences with os.environ[VAR]. Raises ValueError if missing."""
|
|
22
|
+
|
|
23
|
+
def repl(match: re.Match[str]) -> str:
|
|
24
|
+
var_name = match.group(1)
|
|
25
|
+
if var_name not in os.environ:
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"Environment variable {var_name!r} referenced in config but not set"
|
|
28
|
+
)
|
|
29
|
+
return os.environ[var_name]
|
|
30
|
+
|
|
31
|
+
return _ENV_VAR_PATTERN.sub(repl, value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DiscordChannelConfig(BaseModel):
|
|
35
|
+
enabled: bool = False
|
|
36
|
+
token: str = ""
|
|
37
|
+
channel_allowlist: list[str] = Field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(extra="forbid")
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def resolved_token(self) -> str:
|
|
43
|
+
return _substitute_env_vars(self.token)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class WebcamChannelConfig(BaseModel):
|
|
47
|
+
enabled: bool = False
|
|
48
|
+
|
|
49
|
+
model_config = ConfigDict(extra="forbid")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GitHubBackupConfig(BaseModel):
|
|
53
|
+
enabled: bool = False
|
|
54
|
+
repo_url: str = ""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(extra="forbid")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ChannelsConfig(BaseModel):
|
|
60
|
+
discord: DiscordChannelConfig = Field(default_factory=DiscordChannelConfig)
|
|
61
|
+
webcam: WebcamChannelConfig = Field(default_factory=WebcamChannelConfig)
|
|
62
|
+
github_backup: GitHubBackupConfig = Field(default_factory=GitHubBackupConfig)
|
|
63
|
+
|
|
64
|
+
model_config = ConfigDict(extra="forbid")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class HatchConfig(BaseModel):
|
|
68
|
+
being_name: str
|
|
69
|
+
being_emoji: str = ""
|
|
70
|
+
primary_human_name: str
|
|
71
|
+
being_role_placeholder: str | None = None
|
|
72
|
+
# endpoint_name is Optional[str]; after validation, None is replaced by being_name_lower.
|
|
73
|
+
# This allows `endpoint_name: foo` in YAML to override, while defaulting to being_name_lower.
|
|
74
|
+
endpoint_name: str | None = None
|
|
75
|
+
vault_root: str | None = None
|
|
76
|
+
daemon_config_dir: str = "~/.agent-core"
|
|
77
|
+
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
|
|
78
|
+
init_missing: bool = False
|
|
79
|
+
author_letter_in_editor: bool = True
|
|
80
|
+
|
|
81
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
82
|
+
|
|
83
|
+
@model_validator(mode="after")
|
|
84
|
+
def _resolve_defaults(self) -> HatchConfig:
|
|
85
|
+
if self.endpoint_name is None:
|
|
86
|
+
self.endpoint_name = self.being_name.lower()
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
@computed_field
|
|
90
|
+
@property
|
|
91
|
+
def being_name_lower(self) -> str:
|
|
92
|
+
return self.being_name.lower()
|
|
93
|
+
|
|
94
|
+
@computed_field
|
|
95
|
+
@property
|
|
96
|
+
def being_name_upper(self) -> str:
|
|
97
|
+
return self.being_name.upper()
|
|
98
|
+
|
|
99
|
+
@computed_field
|
|
100
|
+
@property
|
|
101
|
+
def hatched_date(self) -> str:
|
|
102
|
+
return date.today().isoformat()
|
|
103
|
+
|
|
104
|
+
@computed_field
|
|
105
|
+
@property
|
|
106
|
+
def discord_token_env(self) -> str:
|
|
107
|
+
return f"DISCORD_{self.being_name_upper}_TOKEN"
|
|
108
|
+
|
|
109
|
+
def resolved_vault_root(self) -> Path:
|
|
110
|
+
root = Path(self.vault_root or "~").expanduser().resolve()
|
|
111
|
+
return (root / f".{self.being_name_lower}").resolve()
|
|
112
|
+
|
|
113
|
+
def resolved_vault_memory_path(self) -> Path:
|
|
114
|
+
return self.resolved_vault_root() / "Memory"
|
|
115
|
+
|
|
116
|
+
def resolved_daemon_config_dir(self) -> Path:
|
|
117
|
+
return Path(self.daemon_config_dir).expanduser().resolve()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Write daemon-config fragments for a hatched being.
|
|
2
|
+
|
|
3
|
+
Outputs (under <daemon_config_dir>):
|
|
4
|
+
- endpoints.d/<being>.yaml — claude_code_mcp + briefs.<being> always; +
|
|
5
|
+
discord-<being> and webcam-<being> if those channels were enabled.
|
|
6
|
+
- jobs.d/<being>.yaml — 5 always-on scheduler jobs prefixed with being
|
|
7
|
+
name; + <being>-github_backup if GitHub backup was enabled.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from agent_core_hatchery.config import HatchConfig
|
|
17
|
+
from agent_core_hatchery.hatcher import TEMPLATES_DIR
|
|
18
|
+
from agent_core_hatchery.renderer import Renderer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DaemonConfigWriter:
|
|
22
|
+
def __init__(self, config: HatchConfig, templates_dir: Path | None = None) -> None:
|
|
23
|
+
self._config = config
|
|
24
|
+
self._templates_dir = templates_dir or TEMPLATES_DIR
|
|
25
|
+
self._renderer = Renderer(config)
|
|
26
|
+
|
|
27
|
+
def write_all(self) -> list[Path]:
|
|
28
|
+
written: list[Path] = []
|
|
29
|
+
written.append(self._write_endpoints_fragment())
|
|
30
|
+
written.append(self._write_jobs_fragment())
|
|
31
|
+
return written
|
|
32
|
+
|
|
33
|
+
def _write_endpoints_fragment(self) -> Path:
|
|
34
|
+
from agent_core_hatchery.channels.discord import scaffold_discord
|
|
35
|
+
from agent_core_hatchery.channels.webcam import scaffold_webcam
|
|
36
|
+
|
|
37
|
+
always_on_template = (self._templates_dir / "daemon-fragments" / "endpoints.yaml.j2").read_text(
|
|
38
|
+
encoding="utf-8"
|
|
39
|
+
)
|
|
40
|
+
always_on_yaml = self._renderer.render_string(always_on_template)
|
|
41
|
+
merged: dict = yaml.safe_load(always_on_yaml) or {}
|
|
42
|
+
merged.setdefault("endpoints", [])
|
|
43
|
+
|
|
44
|
+
for scaffold in (scaffold_discord, scaffold_webcam):
|
|
45
|
+
block = scaffold(self._config)
|
|
46
|
+
if block is not None:
|
|
47
|
+
merged["endpoints"].append(block)
|
|
48
|
+
|
|
49
|
+
dest_dir = self._config.resolved_daemon_config_dir() / "endpoints.d"
|
|
50
|
+
dest = dest_dir / f"{self._config.being_name_lower}.yaml"
|
|
51
|
+
if dest.exists():
|
|
52
|
+
raise FileExistsError(
|
|
53
|
+
f"daemon endpoints fragment already exists: {dest.name} "
|
|
54
|
+
f"(refusing to overwrite — mv aside or remove manually)"
|
|
55
|
+
)
|
|
56
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
dest.write_text(yaml.safe_dump(merged, sort_keys=False), encoding="utf-8")
|
|
58
|
+
return dest
|
|
59
|
+
|
|
60
|
+
def _write_jobs_fragment(self) -> Path:
|
|
61
|
+
from agent_core_hatchery.channels.github_backup import scaffold_github_backup
|
|
62
|
+
|
|
63
|
+
always_on_template = (self._templates_dir / "daemon-fragments" / "jobs.yaml.j2").read_text(
|
|
64
|
+
encoding="utf-8"
|
|
65
|
+
)
|
|
66
|
+
always_on_yaml = self._renderer.render_string(always_on_template)
|
|
67
|
+
merged: dict = yaml.safe_load(always_on_yaml) or {}
|
|
68
|
+
|
|
69
|
+
github_job = scaffold_github_backup(self._config)
|
|
70
|
+
if github_job:
|
|
71
|
+
for k, v in github_job.items():
|
|
72
|
+
merged[k] = v
|
|
73
|
+
|
|
74
|
+
dest_dir = self._config.resolved_daemon_config_dir() / "jobs.d"
|
|
75
|
+
dest = dest_dir / f"{self._config.being_name_lower}.yaml"
|
|
76
|
+
if dest.exists():
|
|
77
|
+
raise FileExistsError(
|
|
78
|
+
f"daemon jobs fragment already exists: {dest.name}"
|
|
79
|
+
)
|
|
80
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
dest.write_text(yaml.safe_dump(merged, sort_keys=False), encoding="utf-8")
|
|
82
|
+
return dest
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Daemon reload and endpoint health-probe for hatch→run handoff (Cβ-3, issue #327).
|
|
2
|
+
|
|
3
|
+
SRP: this module does two things only — restart the daemon process and HTTP-probe the
|
|
4
|
+
new being's endpoint. Imports HatchConfig and DaemonCheckStatus from agent_core_hatchery;
|
|
5
|
+
no cycle exists (daemon_probe is not imported by hatcher.py or config.py).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from agent_core_hatchery.config import HatchConfig
|
|
19
|
+
from agent_core_hatchery.report import DaemonCheckStatus
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_daemon_http_config(daemon_config_dir: Path) -> tuple[str, int]:
|
|
23
|
+
"""Parse agent_core.yaml for the bus HTTP bind host and port.
|
|
24
|
+
|
|
25
|
+
Returns (bind_host, bind_port). Falls back to ("127.0.0.1", 8789) if
|
|
26
|
+
the file does not exist or the keys are absent.
|
|
27
|
+
"""
|
|
28
|
+
config_path = daemon_config_dir / "agent_core.yaml"
|
|
29
|
+
if not config_path.is_file():
|
|
30
|
+
return ("127.0.0.1", 8789)
|
|
31
|
+
try:
|
|
32
|
+
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
33
|
+
except yaml.YAMLError:
|
|
34
|
+
return ("127.0.0.1", 8789)
|
|
35
|
+
http = raw.get("http", {}) if isinstance(raw, dict) else {}
|
|
36
|
+
host = str(http.get("bind_host", "127.0.0.1"))
|
|
37
|
+
port = int(http.get("bind_port", 8789))
|
|
38
|
+
return (host, port)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _stop_daemon(runner=subprocess.run) -> None:
|
|
42
|
+
"""Best-effort daemon stop. Never raises; failures are absorbed."""
|
|
43
|
+
try:
|
|
44
|
+
runner(
|
|
45
|
+
["agent-core", "daemon", "stop"],
|
|
46
|
+
check=False,
|
|
47
|
+
capture_output=True,
|
|
48
|
+
timeout=15,
|
|
49
|
+
)
|
|
50
|
+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
51
|
+
pass # agent-core not on PATH or daemon already stopped — probe decides outcome
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _start_daemon(runner=subprocess.run) -> bool:
|
|
55
|
+
"""Start the daemon. Returns True iff the start command exited 0.
|
|
56
|
+
|
|
57
|
+
Unlike ``_stop_daemon``, a failed START is NOT best-effort: the daemon was
|
|
58
|
+
just stopped, so a failure here leaves the whole fleet offline. The result
|
|
59
|
+
is returned (not swallowed) so the caller can surface it loudly.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
proc = runner(
|
|
63
|
+
["agent-core", "daemon", "start"],
|
|
64
|
+
check=False,
|
|
65
|
+
capture_output=True,
|
|
66
|
+
timeout=15,
|
|
67
|
+
)
|
|
68
|
+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
69
|
+
return False
|
|
70
|
+
return proc.returncode == 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _probe_endpoint(
|
|
74
|
+
host: str,
|
|
75
|
+
port: int,
|
|
76
|
+
endpoint_name: str,
|
|
77
|
+
*,
|
|
78
|
+
timeout: float = 15.0,
|
|
79
|
+
poll_interval: float = 0.5,
|
|
80
|
+
) -> DaemonCheckStatus:
|
|
81
|
+
"""Poll http://{host}:{port}/mcp/{endpoint_name}/ until a response or timeout.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
"reachable_and_registered" — any non-404 HTTP response (endpoint is mounted).
|
|
85
|
+
"reachable_but_missing" — HTTP 404 (daemon up; endpoint not registered).
|
|
86
|
+
"unreachable" — polling timed out or connection always refused.
|
|
87
|
+
"""
|
|
88
|
+
url = f"http://{host}:{port}/mcp/{endpoint_name}/"
|
|
89
|
+
deadline = time.monotonic() + timeout
|
|
90
|
+
|
|
91
|
+
while time.monotonic() < deadline:
|
|
92
|
+
try:
|
|
93
|
+
with urllib.request.urlopen(url, timeout=2.0) as _resp:
|
|
94
|
+
# Any 2xx or redirect means the endpoint is mounted.
|
|
95
|
+
return "reachable_and_registered"
|
|
96
|
+
except urllib.error.HTTPError as exc:
|
|
97
|
+
if exc.code == 404:
|
|
98
|
+
return "reachable_but_missing"
|
|
99
|
+
# Other HTTP error (405, 500, etc.) — server is up, endpoint mounted.
|
|
100
|
+
return "reachable_and_registered"
|
|
101
|
+
except (urllib.error.URLError, OSError):
|
|
102
|
+
# Connection refused / reset — daemon not yet up. Retry.
|
|
103
|
+
time.sleep(poll_interval)
|
|
104
|
+
|
|
105
|
+
return "unreachable"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def reload_and_probe(
|
|
109
|
+
config: HatchConfig,
|
|
110
|
+
*,
|
|
111
|
+
timeout: float = 15.0,
|
|
112
|
+
runner=subprocess.run,
|
|
113
|
+
) -> DaemonCheckStatus:
|
|
114
|
+
"""Stop the daemon, start it, then probe the new being's endpoint.
|
|
115
|
+
|
|
116
|
+
Returns DaemonCheckStatus: see report.py for semantics. Called from cli.py after
|
|
117
|
+
Hatcher.hatch() succeeds (fragments + venv + .mcp.json already written).
|
|
118
|
+
"""
|
|
119
|
+
daemon_config_dir = config.resolved_daemon_config_dir()
|
|
120
|
+
|
|
121
|
+
# Bail early if there is no daemon config to read.
|
|
122
|
+
if not (daemon_config_dir / "agent_core.yaml").is_file():
|
|
123
|
+
return "unreachable"
|
|
124
|
+
|
|
125
|
+
host, port = read_daemon_http_config(daemon_config_dir)
|
|
126
|
+
|
|
127
|
+
_stop_daemon(runner)
|
|
128
|
+
if not _start_daemon(runner):
|
|
129
|
+
# The daemon was stopped but could not be brought back up — the whole
|
|
130
|
+
# fleet is offline. Surface as a hard failure, not a soft "unreachable"
|
|
131
|
+
# (which reads as "daemon up, endpoint will register shortly").
|
|
132
|
+
return "start_failed"
|
|
133
|
+
|
|
134
|
+
return _probe_endpoint(
|
|
135
|
+
host,
|
|
136
|
+
port,
|
|
137
|
+
config.endpoint_name,
|
|
138
|
+
timeout=timeout,
|
|
139
|
+
poll_interval=0.5,
|
|
140
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Elder-letter resolution: canonical-path preferred, bundled-snapshot fallback.
|
|
2
|
+
|
|
3
|
+
Each new being's vault is seeded with letters from beings who came
|
|
4
|
+
before. Each elder's canonical letter lives in HER own vault (vault is
|
|
5
|
+
hers, traveling artifacts honor that). The hatchery package ships
|
|
6
|
+
release-time snapshots as a fallback for users who don't have the
|
|
7
|
+
elder's vault on their machine.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import warnings
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SourceKind(StrEnum):
|
|
21
|
+
CANONICAL = "canonical"
|
|
22
|
+
BUNDLED = "bundled"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ResolvedLetter:
|
|
27
|
+
name: str
|
|
28
|
+
source: Path
|
|
29
|
+
source_kind: SourceKind
|
|
30
|
+
content: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_elder_letters(manifest_path: Path, bundled_dir: Path) -> list[ResolvedLetter]:
|
|
34
|
+
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {}
|
|
35
|
+
resolved: list[ResolvedLetter] = []
|
|
36
|
+
for entry in manifest.get("elders", []) or []:
|
|
37
|
+
name = entry["name"]
|
|
38
|
+
canonical = Path(entry["canonical_path"]).expanduser()
|
|
39
|
+
bundled = bundled_dir / entry["bundled_basename"]
|
|
40
|
+
|
|
41
|
+
if canonical.is_file():
|
|
42
|
+
resolved.append(
|
|
43
|
+
ResolvedLetter(
|
|
44
|
+
name=name,
|
|
45
|
+
source=canonical,
|
|
46
|
+
source_kind=SourceKind.CANONICAL,
|
|
47
|
+
content=canonical.read_text(encoding="utf-8"),
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
elif bundled.is_file():
|
|
51
|
+
resolved.append(
|
|
52
|
+
ResolvedLetter(
|
|
53
|
+
name=name,
|
|
54
|
+
source=bundled,
|
|
55
|
+
source_kind=SourceKind.BUNDLED,
|
|
56
|
+
content=bundled.read_text(encoding="utf-8"),
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
warnings.warn(
|
|
61
|
+
f"Elder letter for {name!r} not found at canonical {canonical} "
|
|
62
|
+
f"or bundled {bundled} — skipping",
|
|
63
|
+
stacklevel=2,
|
|
64
|
+
)
|
|
65
|
+
return resolved
|