Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
41 changes: 40 additions & 1 deletion goal/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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__

Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions project/TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
<!-- AUTO:TICKET_INDEX:END -->
33 changes: 33 additions & 0 deletions project/ticket-063/README.md
Original file line number Diff line number Diff line change
@@ -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)
Empty file.
31 changes: 31 additions & 0 deletions project/ticket-063/ai-codex.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions project/ticket-063/changelog.md
Original file line number Diff line number Diff line change
@@ -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.
95 changes: 95 additions & 0 deletions project/ticket-063/intent.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
12 changes: 12 additions & 0 deletions project/ticket-063/preprompt.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions tests/test_cli_options.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
Expand Down