Skip to content

Commit 435988f

Browse files
committed
Add startup health and update guards
1 parent acb30c9 commit 435988f

9 files changed

Lines changed: 505 additions & 4 deletions

File tree

docs/generated-workspaces.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,32 @@ The autostart path must be:
253253
- silent on MCP stdout except for MCP protocol messages
254254
- not required for direct `./tcx mcp stdio` smoke checks
255255

256+
Generated workspaces also support startup health for Codex sessions. The
257+
`SessionStart` hook writes a compact diagnostic cache at
258+
`.tradingcodex/mainagent/server-status.json`; it does not start services,
259+
update workspaces, or open browsers. `head-manager` then uses
260+
`$use-tradingcodex-server` to run service/MCP doctor checks, call
261+
`./tcx service ensure` when recovery is possible, and open the local dashboard
262+
in the Codex in-app browser when available before it offers a task menu or
263+
starts other work. If project MCP config was created or changed, the user must
264+
fully quit and restart Codex and start a new thread because Codex may not hot
265+
reload project MCP config.
266+
267+
Startup health may also compare the generated workspace version in
268+
`.tradingcodex/generated/module-lock.json` with the currently installed/running
269+
`tcx` package version. The workspace version is the local baseline: if it
270+
differs from the installed `tcx` version, `head-manager` may recommend aligning
271+
the workspace to the installed version. If the installed `tcx` is known to be
272+
older than the latest TradingCodex release, workspace update is blocked until
273+
the package is updated first, so an old package does not refresh the workspace
274+
with stale templates. Update recommendations are scoped to the
275+
new-conversation health pass, not every user turn. If the user declines update
276+
prompts, `head-manager` records the TradingCodex home preference file, normally
277+
`~/.tradingcodex/preferences/update.json`, with
278+
`suppress_update_recommendation=true`; future new conversations should not
279+
recommend automatic workspace updates unless the user removes or changes that
280+
flag, or explicitly asks for an update.
281+
256282
## Hooks
257283

258284
Generated hooks are Python scripts. Hook behavior is guidance, not final
@@ -277,6 +303,10 @@ enforcement.
277303
storage/read/rotation prompts create warning context without activating
278304
investment subagent dispatch unless a separate investment or execution
279305
request remains
306+
- startup server diagnostics: `SessionStart` records compact service and MCP
307+
config status for `head-manager` to repair through `$use-tradingcodex-server`
308+
- update recommendation diagnostics: `SessionStart` records generated workspace
309+
version drift and respects the TradingCodex home update preference file
280310

281311
Hooks load only in trusted projects and may be disabled when
282312
`features.hooks=false`.

docs/interfaces-and-surfaces.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ Top-level commands:
325325
- `tcx mcp install-global --safe`
326326
- `tcx mcp stdio`
327327
- `tcx service runserver`
328+
- `tcx service ensure`
328329

329330
Generated workspace wrapper commands:
330331

docs/roles-skills-and-workflows.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ Head-manager skill responsibilities:
225225
| `orchestrate-workflow` | stage sequencing, lane escalation, and movement across research, thesis, portfolio, risk, order, approval, execution, and postmortem |
226226
| `investment-workflow-map` | universe/workflow classification, source/as-of posture, support gaps, hero/support artifacts, and readiness labels |
227227
| `scenario-quality-gates` | scenario selection, minimum useful role-team shape, artifact expectations, blocked actions, and quality gates |
228-
| `use-tradingcodex-server` | TradingCodex MCP setup plus native broker connector template registration, capability-profile inspection, order-translation previews, read-only sync, and troubleshooting without granting execution authority |
228+
| `use-tradingcodex-server` | Startup health, local dashboard opening, Codex restart guidance, TradingCodex MCP setup, native broker connector template registration, capability-profile inspection, order-translation previews, read-only sync, and troubleshooting without granting execution authority |
229229
| `tradingcodex-operator` | Compatibility entrypoint for one release cycle; redirects users to `$use-tradingcodex-server` and must not add separate broker, approval, or execution authority |
230230
| `external-data-source-gate` | read-only external evidence-source constraints and External MCP Gate honesty |
231231
| `manage-subagents` | fixed-role dispatch mechanics, runtime state/reuse checks, compact briefs, artifact review, and conflict handling |
@@ -333,6 +333,7 @@ experimental submit/cancel execution tools.
333333
## Hooks Are Guidance
334334

335335
- `UserPromptSubmit` handles prompt classification, secret warnings, direct-answer prevention context, and duplicate marker management.
336+
- `SessionStart` writes compact TradingCodex server/MCP diagnostics for `head-manager`; startup recovery and browser opening stay in `$use-tradingcodex-server`.
336337
- Official `UserPromptSubmit` matchers are ignored, so classification happens inside the hook script.
337338
- Hooks use command type only and do not rely on ordering or concurrency between hooks.
338339
- Project-local hooks load only in trusted projects and may be disabled when `features.hooks=false`.

tests/test_python_migration.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,38 @@ def test_service_autostart_reuses_compatible_singleton(monkeypatch, tmp_path: Pa
9898
assert checked == [("127.0.0.1", 48267)]
9999

100100

101+
def test_service_autostart_rejects_incompatible_port_owner(monkeypatch, tmp_path: Path) -> None:
102+
from tradingcodex_cli import service_autostart
103+
104+
monkeypatch.setattr(service_autostart, "_tcp_open", lambda host, port: True)
105+
monkeypatch.setattr(service_autostart, "_service_health", lambda host, port: {})
106+
monkeypatch.setattr(service_autostart, "_start_service", lambda *args: (_ for _ in ()).throw(AssertionError("started over occupied port")))
107+
108+
with pytest.raises(RuntimeError, match="non-TradingCodex service"):
109+
service_autostart.ensure_service_up(tmp_path, timeout=0.01)
110+
111+
112+
def test_service_autostart_rejects_version_and_db_mismatch(monkeypatch) -> None:
113+
from tradingcodex_cli import service_autostart
114+
115+
monkeypatch.setattr(
116+
service_autostart,
117+
"_service_health",
118+
lambda host, port: {"service": "tradingcodex", "version": "999.0.0", "db_path": str(Path("/tmp/current.sqlite3"))},
119+
)
120+
with pytest.raises(RuntimeError, match="version mismatch"):
121+
service_autostart._assert_compatible_service("127.0.0.1", 48267)
122+
123+
monkeypatch.setattr(service_autostart, "tradingcodex_db_path", lambda: Path("/tmp/current.sqlite3"))
124+
monkeypatch.setattr(
125+
service_autostart,
126+
"_service_health",
127+
lambda host, port: {"service": "tradingcodex", "version": TRADINGCODEX_VERSION, "db_path": str(Path("/tmp/other.sqlite3"))},
128+
)
129+
with pytest.raises(RuntimeError, match="DB mismatch"):
130+
service_autostart._assert_compatible_service("127.0.0.1", 48267)
131+
132+
101133
def test_service_runserver_uses_fixed_port_and_skips_duplicate(monkeypatch, tmp_path: Path, capsys) -> None:
102134
from django.core import management
103135
from tradingcodex_cli import __main__ as cli_main
@@ -122,6 +154,23 @@ def test_service_runserver_uses_fixed_port_and_skips_duplicate(monkeypatch, tmp_
122154
assert "TradingCodex service already running at http://127.0.0.1:48267/" in capsys.readouterr().out
123155

124156

157+
def test_service_ensure_uses_autostart_helper(monkeypatch, tmp_path: Path, capsys) -> None:
158+
from tradingcodex_cli import __main__ as cli_main
159+
from tradingcodex_cli import service_autostart
160+
161+
calls: list[tuple[Path, str]] = []
162+
monkeypatch.chdir(tmp_path)
163+
monkeypatch.delenv("TRADINGCODEX_WORKSPACE_ROOT", raising=False)
164+
monkeypatch.setattr(service_autostart, "ensure_service_up", lambda root, addr: calls.append((root, addr)) or True)
165+
166+
cli_main.service(["ensure"])
167+
168+
assert calls == [(tmp_path.resolve(), "127.0.0.1:48267")]
169+
output = capsys.readouterr().out
170+
assert "TradingCodex service started at http://127.0.0.1:48267/" in output
171+
assert "Health: http://127.0.0.1:48267/api/health" in output
172+
173+
125174
def test_manage_runserver_uses_fixed_port_and_skips_duplicate(monkeypatch, capsys) -> None:
126175
from tradingcodex_cli import service_autostart
127176

@@ -501,6 +550,65 @@ def test_user_prompt_hook_auto_routes_plain_investment_requests(tmp_path: Path)
501550
)
502551
assert subagent_brief_gate is None
503552

553+
554+
def test_session_start_update_recommendation_respects_home_preference(tmp_path: Path) -> None:
555+
workspace = make_workspace(tmp_path)
556+
home = tmp_path / "tc-home"
557+
module_lock_path = workspace / ".tradingcodex" / "generated" / "module-lock.json"
558+
module_lock = json.loads(module_lock_path.read_text(encoding="utf-8"))
559+
module_lock["tradingcodex_version"] = "0.0.1"
560+
module_lock_path.write_text(json.dumps(module_lock, indent=2) + "\n", encoding="utf-8")
561+
562+
run(
563+
[sys.executable, str(workspace / ".codex" / "hooks" / "tradingcodex_hook.py"), "session-start"],
564+
workspace,
565+
input_text=json.dumps({}),
566+
env_extra={"TRADINGCODEX_HOME": str(home), "TRADINGCODEX_LATEST_RELEASE_VERSION": TRADINGCODEX_VERSION},
567+
)
568+
server_status = json.loads((workspace / ".tradingcodex" / "mainagent" / "server-status.json").read_text(encoding="utf-8"))
569+
update_status = server_status["update_status"]
570+
assert update_status["workspace_version"] == "0.0.1"
571+
assert update_status["installed_version"] == TRADINGCODEX_VERSION
572+
assert update_status["package_version"] == TRADINGCODEX_VERSION
573+
assert update_status["latest_release_version"] == TRADINGCODEX_VERSION
574+
assert update_status["latest_release_status"] == "ok"
575+
assert update_status["versions_match"] is False
576+
assert update_status["workspace_update_available"] is True
577+
assert update_status["workspace_update_allowed"] is True
578+
assert update_status["workspace_update_recommended"] is True
579+
assert update_status["package_update_required_first"] is False
580+
preference_path = home / "preferences" / "update.json"
581+
assert update_status["preference_path"] == str(preference_path)
582+
583+
run(
584+
[sys.executable, str(workspace / ".codex" / "hooks" / "tradingcodex_hook.py"), "session-start"],
585+
workspace,
586+
input_text=json.dumps({}),
587+
env_extra={"TRADINGCODEX_HOME": str(home), "TRADINGCODEX_LATEST_RELEASE_VERSION": "999.0.0"},
588+
)
589+
blocked_status = json.loads((workspace / ".tradingcodex" / "mainagent" / "server-status.json").read_text(encoding="utf-8"))
590+
blocked_update = blocked_status["update_status"]
591+
assert blocked_update["workspace_update_available"] is True
592+
assert blocked_update["workspace_update_allowed"] is False
593+
assert blocked_update["workspace_update_recommended"] is False
594+
assert blocked_update["package_update_required_first"] is True
595+
assert "older than the latest release" in blocked_update["blocked_reason"]
596+
597+
preference_path.parent.mkdir(parents=True)
598+
preference_path.write_text(json.dumps({"suppress_update_recommendation": True}) + "\n", encoding="utf-8")
599+
run(
600+
[sys.executable, str(workspace / ".codex" / "hooks" / "tradingcodex_hook.py"), "session-start"],
601+
workspace,
602+
input_text=json.dumps({}),
603+
env_extra={"TRADINGCODEX_HOME": str(home), "TRADINGCODEX_LATEST_RELEASE_VERSION": TRADINGCODEX_VERSION},
604+
)
605+
suppressed_status = json.loads((workspace / ".tradingcodex" / "mainagent" / "server-status.json").read_text(encoding="utf-8"))
606+
suppressed_update = suppressed_status["update_status"]
607+
assert suppressed_update["workspace_update_available"] is True
608+
assert suppressed_update["workspace_update_allowed"] is True
609+
assert suppressed_update["workspace_update_recommended"] is False
610+
assert suppressed_update["update_recommendation_suppressed"] is True
611+
504612
assert run_user_prompt_hook(workspace, "Update the docs table") is None
505613
assert run_user_prompt_hook(workspace, "Create a quality income strategy for dividend stocks") is None
506614

@@ -603,6 +711,16 @@ def test_repo_skill_templates_keep_instruction_boundary() -> None:
603711
assert (use_server / "scripts" / "summarize_connector_status.py").exists()
604712
skill_text = (use_server / "SKILL.md").read_text(encoding="utf-8")
605713
assert "name: use-tradingcodex-server" in skill_text
714+
assert "## Startup Health And Dashboard" in skill_text
715+
assert "./tcx service ensure" in skill_text
716+
assert "http://127.0.0.1:48267/api/health" in skill_text
717+
assert "required startup action for a new conversation" in skill_text
718+
assert "make the browser visible" in skill_text
719+
assert "workspace_update_allowed" in skill_text
720+
assert "package_update_required_first" in skill_text
721+
assert "~/.tradingcodex/preferences/update.json" in skill_text
722+
assert "suppress_update_recommendation" in skill_text
723+
assert "fully quit and restart Codex" in skill_text
606724
assert "BrokerCapabilityProfile" not in skill_text
607725
use_server_metadata = yaml.safe_load((use_server / "agents" / "openai.yaml").read_text(encoding="utf-8"))
608726
assert "$use-tradingcodex-server" in use_server_metadata["interface"]["default_prompt"]
@@ -759,6 +877,23 @@ def test_python_generator_creates_workspace_contract(tmp_path: Path) -> None:
759877
assert "task_name" not in orchestration_guidance
760878
hook_text = (workspace / ".codex" / "hooks" / "tradingcodex_hook.py").read_text(encoding="utf-8")
761879
assert 'payload.get("agent_type")' in hook_text
880+
assert "server-status.json" in hook_text
881+
session_start = run(
882+
[sys.executable, str(workspace / ".codex" / "hooks" / "tradingcodex_hook.py"), "session-start"],
883+
workspace,
884+
input_text=json.dumps({}),
885+
)
886+
assert session_start.stdout == ""
887+
server_status = json.loads((workspace / ".tradingcodex" / "mainagent" / "server-status.json").read_text(encoding="utf-8"))
888+
assert server_status["service_addr"] == "127.0.0.1:48267"
889+
assert server_status["dashboard_url"] == "http://127.0.0.1:48267/"
890+
assert server_status["health_url"] == "http://127.0.0.1:48267/api/health"
891+
assert server_status["mcp_config_present"] is True
892+
assert server_status["restart_codex_required"] is False
893+
assert server_status["update_status"]["versions_match"] is True
894+
assert server_status["update_status"]["workspace_update_available"] is False
895+
assert server_status["update_status"]["workspace_update_recommended"] is False
896+
assert server_status["recommended_action"]
762897
assert not (workspace / ".tradingcodex" / "state" / "tradingcodex.sqlite3").exists()
763898
assert not (workspace / ".tradingcodex" / "state" / "paper-portfolio.json").exists()
764899
db_path = run(["./tcx", "db", "path"], workspace).stdout.strip()
@@ -819,6 +954,7 @@ def test_python_generator_creates_workspace_contract(tmp_path: Path) -> None:
819954
assert hooks["SubagentStart"][0]["matcher"]
820955
service_usage = run(["./tcx", "service", "nope"], workspace, expect_ok=False)
821956
assert "Usage: tcx service runserver [addrport] [django runserver args]" in service_usage.stderr
957+
assert "tcx service ensure [addrport]" in service_usage.stderr
822958
agent_files = sorted((workspace / ".codex" / "agents").glob("*.toml"))
823959
assert len(agent_files) == 9
824960
actual_mcp_tools = {tool["name"] for tool in static_mcp_tools()}
@@ -831,6 +967,18 @@ def test_python_generator_creates_workspace_contract(tmp_path: Path) -> None:
831967
assert "You are the `head-manager` agent" in head_manager_instructions
832968
assert "Codex-based local trading harness" in head_manager_instructions
833969
assert "asset-management workflow team" in head_manager_instructions
970+
assert "## New conversation health" in head_manager_instructions
971+
assert "When a new Codex conversation starts" in head_manager_instructions
972+
assert "before greeting with a task menu" in head_manager_instructions
973+
assert ".tradingcodex/mainagent/server-status.json" in head_manager_instructions
974+
assert "Opening the TradingCodex dashboard is mandatory" in head_manager_instructions
975+
assert "Do not merely" in head_manager_instructions
976+
assert "workspace_update_allowed" in head_manager_instructions
977+
assert "package_update_required_first" in head_manager_instructions
978+
assert "~/.tradingcodex/preferences/update.json" in head_manager_instructions
979+
assert "suppress_update_recommendation" in head_manager_instructions
980+
assert "fully quit and restart" in head_manager_instructions
981+
assert "Codex may not hot" in head_manager_instructions
834982
assert "not an autonomous trading bot" not in head_manager_instructions
835983
assert "# How you work" in head_manager_instructions
836984
assert "# TradingCodex guardrails" in head_manager_instructions

tradingcodex_cli/__main__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def print_help() -> None:
117117
tcx research list
118118
tcx mcp stdio|external
119119
tcx service runserver [addrport] [django runserver args]
120+
tcx service ensure [addrport]
120121
""")
121122

122123

tradingcodex_cli/commands/bootstrap.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,19 @@ def update(argv: list[str]) -> None:
104104

105105
def service(argv: list[str]) -> None:
106106
sub = argv[0] if argv else "runserver"
107+
if sub == "ensure":
108+
from tradingcodex_cli.service_autostart import ensure_service_up, service_http_url
109+
110+
root = configure_workspace_env(Path.cwd())
111+
addr = argv[1] if len(argv) > 1 else DEFAULT_SERVICE_ADDR
112+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tradingcodex_service.settings")
113+
started = ensure_service_up(root, addr=addr)
114+
dashboard_url = service_http_url(addr)
115+
print(f"TradingCodex service {'started' if started else 'ready'} at {dashboard_url}")
116+
print(f"Health: {dashboard_url.rstrip('/')}/api/health")
117+
return
107118
if sub != "runserver":
108-
raise ValueError(f"Usage: {PROGRAM_NAME} service runserver [addrport] [django runserver args]")
119+
raise ValueError(f"Usage: {PROGRAM_NAME} service runserver [addrport] [django runserver args]\n {PROGRAM_NAME} service ensure [addrport]")
109120
from django.core.management import execute_from_command_line
110121
from tradingcodex_cli.service_autostart import compatible_service_running, service_http_url
111122

0 commit comments

Comments
 (0)