From e6bb481fd1970fe1fb1f83504b79ae37ef171e17 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Sun, 16 Aug 2026 21:42:14 +0200 Subject: [PATCH] [ticket-063] fix(cli): re-exec after pip self-update to avoid mixed-version runs pip rewrites package files under the running process, so lazy imports could load new modules while goal.cli stayed old. Restart via `python -m goal` with GOAL_SELF_UPDATED guard after a successful update. Co-authored-by: Cursor Co-authored-by: Koru Agent Co-authored-by: Cursor --- TODO.md | 4 ++ goal/cli/__init__.py | 41 +++++++++++- project/TICKETS.md | 1 + project/ticket-063/README.md | 33 ++++++++++ project/ticket-063/ai-codex-logs.txt | 0 project/ticket-063/ai-codex.md | 31 +++++++++ project/ticket-063/changelog.md | 7 ++ project/ticket-063/intent.json | 95 ++++++++++++++++++++++++++++ project/ticket-063/preprompt.md | 12 ++++ tests/test_cli_options.py | 44 +++++++++++++ 10 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 project/ticket-063/README.md create mode 100644 project/ticket-063/ai-codex-logs.txt create mode 100644 project/ticket-063/ai-codex.md create mode 100644 project/ticket-063/changelog.md create mode 100644 project/ticket-063/intent.json create mode 100644 project/ticket-063/preprompt.md diff --git a/TODO.md b/TODO.md index 13dcd688..6fbbb2ba 100644 --- a/TODO.md +++ b/TODO.md @@ -9,6 +9,10 @@ Docker, protected CI and exact-head Validator approval pass; PR #103 merged unchanged as `af08932...`; classification: `BUG / P1 / regression`. +- [ ] Deliver [ticket-063](project/ticket-063/README.md): re-exec the CLI after + a successful pip self-update so lazy imports cannot mix package versions. + State: `IN_PROGRESS / PUBLICATION`; classification: `BUG / P1 / regression`. + - [ ] Deliver [ticket-061](project/ticket-061/README.md): publish the merged retry-safe runtime, doctor, producer and tracked configuration fixes as Goal 2.1.300, then prove the public package on a disposable Glon checkout. State: diff --git a/goal/cli/__init__.py b/goal/cli/__init__.py index 44b7b790..50978226 100644 --- a/goal/cli/__init__.py +++ b/goal/cli/__init__.py @@ -29,6 +29,10 @@ DOCS_URL = "https://github.com/wronai/goal#readme" +# Set on the child process after a self-update re-exec so a botched install +# (new files on PyPI, old ones still first on sys.path) cannot loop forever. +SELF_UPDATE_REEXEC_ENV = "GOAL_SELF_UPDATED" + def _has_cli_flag(args: List[str], short: str, long: str) -> bool: """Detect a short or long CLI flag, including combined forms like -au.""" @@ -360,16 +364,50 @@ def _show_goal_version_banner() -> Optional[str]: return None +def _reexec_after_self_update() -> None: + """Restart the current invocation on the freshly installed goal. + + pip rewrites the package files underneath the running process, so modules + imported lazily afterwards (``goal.push.core``, ``goal.governance.*``) come + from the new release while ``goal.cli`` is still the old one. Any contract + that changed between the two versions then breaks in a way that looks like a + bug in the new code — e.g. new push code reading a ``ctx.obj`` key that the + old group callback never set, so ``goal -a`` reports that it needs `goal -a`. + Re-exec so a single version serves the whole run. + """ + click.echo(click.style(" ↻ Restarting with the updated goal...", fg="cyan")) + os.environ[SELF_UPDATE_REEXEC_ENV] = "1" + try: + sys.stdout.flush() + sys.stderr.flush() + os.execv(sys.executable, [sys.executable, "-m", "goal", *sys.argv[1:]]) + except OSError as exc: + # Never fall through into the mixed-version state the re-exec exists to + # avoid; the user only has to run the same command again. + raise click.ClickException( + f"updated goal but could not restart automatically ({exc}); " + "re-run your command" + ) + + def _maybe_self_update(latest_version: Optional[str], yes: bool) -> None: """Offer (or, under -y, perform) the self-update `_show_goal_version_banner` only ever suggested before. Skips entirely for non-interactive runs without -y so scripted/CI invocations never block on a prompt or silently start a network install mid-command. + + A successful update re-execs the command, so this function does not return + in that case. """ if not isinstance(latest_version, str) or not latest_version: return if not yes and not sys.stdin.isatty(): return + if os.environ.get(SELF_UPDATE_REEXEC_ENV): + # Already restarted once and the version still looks stale: the install + # landed somewhere that isn't first on sys.path. Keep running the old + # code rather than re-updating and re-exec'ing forever. + return from goal import __version__ @@ -382,7 +420,8 @@ def _maybe_self_update(latest_version: Optional[str], yes: bool) -> None: ): return - _auto_update_goal(__version__, latest_version) + if _auto_update_goal(__version__, latest_version): + _reexec_after_self_update() def _explicit_ascii_flag(argv: list[str] | None = None) -> bool: diff --git a/project/TICKETS.md b/project/TICKETS.md index c2683973..dcfe0212 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -65,4 +65,5 @@ This file indexes governance tickets without taking ownership of | **ticket-060** | [`README.md`](./ticket-060/README.md) | [`preprompt.md`](./ticket-060/preprompt.md) | - | [`ai-codex.md`](./ticket-060/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-060/ai-codex-logs.txt) | [`changelog.md`](./ticket-060/changelog.md) | | **ticket-061** | [`README.md`](./ticket-061/README.md) | [`preprompt.md`](./ticket-061/preprompt.md) | - | [`ai-codex.md`](./ticket-061/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-061/ai-codex-logs.txt) | [`changelog.md`](./ticket-061/changelog.md) | | **ticket-062** | [`README.md`](./ticket-062/README.md) | [`preprompt.md`](./ticket-062/preprompt.md) | - | [`ai-codex.md`](./ticket-062/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-062/ai-codex-logs.txt) | [`changelog.md`](./ticket-062/changelog.md) | +| **ticket-063** | [`README.md`](./ticket-063/README.md) | [`preprompt.md`](./ticket-063/preprompt.md) | - | [`ai-codex.md`](./ticket-063/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-063/ai-codex-logs.txt) | [`changelog.md`](./ticket-063/changelog.md) | diff --git a/project/ticket-063/README.md b/project/ticket-063/README.md new file mode 100644 index 00000000..8fa5c0af --- /dev/null +++ b/project/ticket-063/README.md @@ -0,0 +1,33 @@ +# Ticket 063: Re-exec CLI after pip self-update + +- **ID**: ticket-063 +- **Owner**: unresolved:human +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Created**: 2026-08-16 + +## Goal and scope + +After a successful pip self-update, Goal must re-exec into the refreshed +package so lazy imports cannot mix a new `goal` tree with an old `goal.cli` +module. Keep the behavior guarded and test-covered. + +## Acceptance criteria + +- [x] AC-01: Session request to finish the unbound push records + `SESSION_EXECUTION_AUTHORIZATION` for this bounded defect. +- [x] AC-02: Successful self-update sets a one-shot guard and re-executes via + `python -m goal` with the original argv. +- [x] AC-03: Focused CLI tests cover the re-exec path. +- [ ] AC-04: Governed delivery publishes the bound commit without hook bypass. + +## Boundary + +- Touch only the CLI self-update re-exec path and its tests. +- Do not publish Goal 2.1.300 or modify ticket-061 release carriers. +- Distinct workstream from ticket-061 (`application` vs `integration`). + +## Participants + +- Human participant: unresolved; no user-* file was created by this script. +- Agent participant: [ai-codex.md](ai-codex.md) diff --git a/project/ticket-063/ai-codex-logs.txt b/project/ticket-063/ai-codex-logs.txt new file mode 100644 index 00000000..e69de29b diff --git a/project/ticket-063/ai-codex.md b/project/ticket-063/ai-codex.md new file mode 100644 index 00000000..615aa77d --- /dev/null +++ b/project/ticket-063/ai-codex.md @@ -0,0 +1,31 @@ +--- +participant-id: agent:codex +participant: codex +role: agent +ticket: ticket-063 +--- +# Participant: codex (AI agent) + +## Understanding + +Pip self-update can rewrite package files under a still-running Goal process. +Lazy imports then load new modules while `goal.cli` stays old. Re-exec after a +successful update with a one-shot guard closes that mixed-version window. + +## Execution plan + +1. Bind the existing CLI fix commit to this application-workstream ticket. +2. Keep ticket-061 (integration / 2.1.300 publication) untouched. +3. Deliver through governed push without hook bypass. + +## Actual changes + +- CLI re-exec after pip self-update already implemented in + `goal/cli/__init__.py` with coverage in `tests/test_cli_options.py`. +- Scaffolded ticket-063 and bound the unpushed candidate subject to + `[ticket-063]`. + +## Authority + +- `SESSION_EXECUTION_AUTHORIZATION`: user asked to finish remaining blockers, + bind the unbound Goal commit per governance, and push without skipping hooks. diff --git a/project/ticket-063/changelog.md b/project/ticket-063/changelog.md new file mode 100644 index 00000000..09be3787 --- /dev/null +++ b/project/ticket-063/changelog.md @@ -0,0 +1,7 @@ +# Ticket Changelog (ticket-063) + +## [0.1.0] - 2026-08-16 + +- Bound CLI self-update re-exec fix and focused tests to this ticket. +- Initial governance scaffold created. +- No human participant identity or content was generated. diff --git a/project/ticket-063/intent.json b/project/ticket-063/intent.json new file mode 100644 index 00000000..df346503 --- /dev/null +++ b/project/ticket-063/intent.json @@ -0,0 +1,95 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-063", + "summary": "Re-exec CLI after pip self-update to avoid mixed-version runs", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "regression" + }, + "allowedPaths": [ + "goal/cli/__init__.py", + "tests/test_cli_options.py", + "project/ticket-063/**", + "TODO.md", + "project/TICKETS.md" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md", + ".github/**", + "pyproject.toml", + "uv.lock", + "VERSION", + "goal/__init__.py", + ".env", + "**/*.pem", + "**/*secret*" + ], + "stacks": ["python", "docker"], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "45cdcba3201481565a5b3c56832ed9df0c3c8086", + "targetBranch": "main", + "outcome": "Self-update re-exec prevents mixed-version CLI imports after pip rewrite", + "nonGoals": [ + "No Goal 2.1.300 publication", + "No ticket-061 release carrier changes", + "No dependency or public API change" + ], + "complexity": "S", + "estimatedMinutes": 20, + "budgets": { + "maxImplementationFiles": 2, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "After a successful pip self-update, re-exec via python -m goal with a one-shot GOAL_SELF_UPDATED guard so the refreshed package tree is loaded before further CLI work", + "components": [ + { + "name": "cli-self-update-reexec", + "paths": [ + "goal/cli/__init__.py", + "tests/test_cli_options.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": {"impact": "none", "states": [], "evidence": []}, + "rollback": "Revert the re-exec guard and tests; prior mixed-version risk returns after pip self-update" + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-02", + "commands": [ + "pytest -q tests/test_cli_options.py", + "ruff check goal/cli/__init__.py tests/test_cli_options.py" + ], + "evidence": "project/ticket-063/ai-codex-logs.txt" + }, + { + "criterion": "AC-03", + "commands": [ + "pytest -q tests/test_cli_options.py" + ], + "evidence": "project/ticket-063/ai-codex-logs.txt" + }, + { + "criterion": "AC-04", + "commands": [ + "pytest -q", + "./project/governance-check.sh" + ], + "evidence": "project/ticket-063/ai-codex-logs.txt" + } + ] + } +} diff --git a/project/ticket-063/preprompt.md b/project/ticket-063/preprompt.md new file mode 100644 index 00000000..ef94c20a --- /dev/null +++ b/project/ticket-063/preprompt.md @@ -0,0 +1,12 @@ +# Ticket preprompt + +- **Task ID**: ticket-063 +- **Task title**: Re-exec CLI after pip self-update +- **Created**: 2026-08-16T19:41:23Z + +Keep executable implementation outside this governance/evidence directory. +Read a human-owned user-*.md file only when one exists. +The request to execute this work creates SESSION_EXECUTION_AUTHORIZATION; +proceed within the recorded intent without a redundant confirmation prompt. +Require new authority for destructive action, secrets, external coordination, +material objective expansion and trusted merge approval. diff --git a/tests/test_cli_options.py b/tests/test_cli_options.py index 6859f621..cad07e40 100644 --- a/tests/test_cli_options.py +++ b/tests/test_cli_options.py @@ -1,6 +1,10 @@ +import os import subprocess import sys from unittest import mock + +import click +import pytest from click.testing import CliRunner import goal.cli as goal_cli @@ -300,6 +304,46 @@ def test_maybe_self_update_runs_without_prompt_when_yes(monkeypatch) -> None: assert len(called) == 1 +def test_maybe_self_update_reexecs_after_successful_update(monkeypatch) -> None: + # pip rewrites the package under the running process, so the run must + # restart instead of mixing old goal.cli with new lazily-imported modules. + monkeypatch.delenv(goal_cli.SELF_UPDATE_REEXEC_ENV, raising=False) + monkeypatch.setattr(goal_cli, "_auto_update_goal", lambda *a: True) + monkeypatch.setattr(sys, "argv", ["goal", "-a"]) + execs = [] + monkeypatch.setattr(goal_cli.os, "execv", lambda *a: execs.append(a)) + + goal_cli._maybe_self_update("9.9.9", yes=True) + + assert execs == [(sys.executable, [sys.executable, "-m", "goal", "-a"])] + assert os.environ[goal_cli.SELF_UPDATE_REEXEC_ENV] == "1" + + +def test_maybe_self_update_does_not_update_again_after_reexec(monkeypatch) -> None: + monkeypatch.setenv(goal_cli.SELF_UPDATE_REEXEC_ENV, "1") + called = [] + monkeypatch.setattr(goal_cli, "_auto_update_goal", lambda *a: called.append(a)) + + goal_cli._maybe_self_update("9.9.9", yes=True) + + assert called == [] + + +def test_maybe_self_update_fails_loudly_when_reexec_is_impossible(monkeypatch) -> None: + monkeypatch.delenv(goal_cli.SELF_UPDATE_REEXEC_ENV, raising=False) + monkeypatch.setattr(goal_cli, "_auto_update_goal", lambda *a: True) + monkeypatch.setattr( + goal_cli.os, + "execv", + lambda *a: (_ for _ in ()).throw(OSError("exec failed")), + ) + + with pytest.raises(click.ClickException) as excinfo: + goal_cli._maybe_self_update("9.9.9", yes=True) + + assert "re-run your command" in str(excinfo.value) + + def test_diagnose_broken_python_env_detects_version_mismatch(monkeypatch, tmp_path) -> None: # goal_cli._diagnose_broken_python_env is goal.pyenv_health.diagnose (see # tests/test_pyenv_health.py for the module's own thorough coverage);