diff --git a/CLAUDE.md b/CLAUDE.md index 0926cfc4..624bef5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ New principle: 改哲学:单文件 SKILL.md + intra-file anchors 是被认可的 - **边界清晰,职责分层**:本文件承载**跨 skill 边界**与**仓库级宪法约束**;单个 skill 的工作流细则、术语定义、当前状态归该 skill 自维护,不复制回本文件。 - **事实源唯一**:同一约束禁止在多处平行声明。版本号 → `.version-bump.json`;host 运行时事实 → `host.env`;skill 行为 → 该 skill 的 SKILL.md 与 `scripts/test_*.py`。 - **抽象优先,行为契约**:skill 间通过 `host.env` + 文件 artifact + GitHub API 等稳定边界协作,不耦合彼此内部脚本;命名跟随职责,不泄露 runtime / 内部实现细节。 -- **强类型边界,窄扩展点**:任何 controller-runtime 例外必须 narrow allowlist + no lifecycle authority by default;授权来源必须 durable artifact + 仓库级文档双重锚定。#53 是唯一 integration-branch git carveout:`integration sync daemon` 在专用 integration worktree 内的 integration-branch git allowlist(`git fetch` / `git ls-remote --exit-code --heads origin $INTEGRATION_BRANCH` / `rev-list` / `rev-parse` / `merge-base` / `reset --hard` / `rebase --rebase-merges` / `merge --ff-only|--no-ff` / `push HEAD:$INTEGRATION_BRANCH` / force-with-lease adoption),不得 commit worker diff、create/merge/close PR、开关 issue/PR/label、tag/release,不得作为 generic lifecycle actor。#191 是唯一跨设备 active-controller lease carveout:GitHub/已 push git 面只承载一个全局 `ActiveControllerLease`,允许 read/acquire/renew 专用 lease artifact 并暴露 owner/expiry;禁止 worker diff commit、issue/PR create/merge/close/edit、label mutation、tag/release、per-work claim、host-defined lease scope、跨设备 floor 聚合、daemon ownership matrix、active-active scheduler、generic lifecycle actor。#193 中 issue/PR author.login 与 updatedAt 仅可作为 planning/routing/stale metadata,不得作为 side-effect authorization、per-work owner authority、claim/lease scope 或 takeover permit;issue/PR target 写副作用的跨设备 permit 只来自 #191 ActiveControllerLease。#238 是唯一 closed managed item phase-label reconciliation carveout:active-controller owner 的 checked-in `closed-label-reconciler` 只可对 CLOSED `crnd:lifecycle:managed` issue/PR 做 phase-label reconciliation,移除 phase/cleanup/stuck label 并加 exactly one terminal phase `crnd:phase:merged` 或 `crnd:phase:closed`;禁止 open item mutation、issue/PR create/close/reopen/body/title edit、PR merge、human/triage/milestone/lifecycle label mutation、tag/release、generic lifecycle actor。#322 是唯一 controller-owned release publication carveout:active-controller owner 的 `ReleasePublisher` 只可在 `ReleasePublishPreflight` 验证 `RELEASE_AUTO_ENABLE=true`、fresh release-candidate/release-decision、decision_digest、target_ref、mapped manifest from_version、required checks 全绿后,走同一 publish 主链路:首次发布运行 `python3 .github/scripts/bump_version.py --version `、`git add .version-bump.json `、`git commit -m "Release v"`;already-bumped reentry 仅当 only preflight mismatch 是 mapped manifests 已==`to_version` 且 `git show -s --format=%s HEAD` 证明 HEAD subject 精确为 `Release v` 时跳过这三步。两条路径随后都必须运行 `git rev-parse HEAD`、`git fetch origin HEAD`、`git rev-list --count HEAD..origin/HEAD`、`git push origin HEAD`、通过 `ReleaseRequiredChecksProjection` 读取 `gh api repos//commits//check-runs --paginate --slurp` 或 reentry 的 `gh api repos//commits//check-runs --paginate --slurp` 并确认该 exact fresh SHA required checks 全绿后才运行(或 reentry 时确认该 exact fresh/reentry SHA required checks 全绿后才运行) `gh release create v --target --generate-notes [--prerelease]` 或 reentry 的 `gh release create v --target --generate-notes [--prerelease]`,并写 `.refactor-loop/state/release-publish-result.json`;禁止 public release-publish CLI、workflow tag/release creation、tag target without exact-SHA green checks、`git tag`、force-push、release edit/delete/upload、approval-ticket/emoji gate、issue/PR/label lifecycle、merge/close、generic lifecycle actor。#396 是唯一 unattended wakeup-runner carveout:active-controller owner 的 checked-in `wakeup-runner` 只可消费 `wakeup-plan` 产出的 evidence-bound closed action projection,并对每个 action 重新验证 clean `EXIT=0` source marker、review truth table、OPEN/live GitHub state、#191 owner、release #322 preflight 或 helper-specific precondition 后,机械调用既有 controller helper 或 #396 narrow helper。`wakeup-plan` 是唯一 action projection fact source但不是 standalone authorization source;daemon 不得读 prompt body 决策,不得新增 `ControllerTurnDecision`/controller-turn worker/schema,不得接受 argv/shell/cmd/command_line/commands/env/git/gh/executor/lifecycle_authority/lifecycle_owner/generic command fields,不得把 `.refactor-loop/host.env` 当 host production SSOT。允许动作仅限 spawn codex、named helper `dispatch_design_consensus` through phase9-router deterministic routes、named helper `dispatch_consensus_implementation`、named helper `publish_implementation_output`、named helper `open_release_rollup_pr_from_action`、publish worker output、dispatch reviewers/fix/remote-ci worker、apply triage decision、merge PR under review truth table、close managed item from drop marker、publish release through #322;禁止任意 git/gh 命令、workflow tag/release、label/merge/close outside existing helper or named #396 helper、active-active scheduler、generic lifecycle actor。#403 是唯一大 issue 分解 carveout:active-controller owner 的 checked-in apply helper 只可消费已验证的 `IssueDecompositionPlan`,创建 `crnd:lifecycle:managed` child design issues 并评论父 issue;父 epic 保持 open/tracking,禁止 close/reopen/body-title edit,禁止 daemon/worker 建 issue、public issue factory、wakeup-plan decompose 投影或 generic lifecycle actor。通用授权、escape hatch、宽口径修宪一律视为设计未完成。 +- **强类型边界,窄扩展点**:任何 controller-runtime 例外必须 narrow allowlist + no lifecycle authority by default;授权来源必须 durable artifact + 仓库级文档双重锚定。#53 是唯一 integration-branch git carveout:`integration sync daemon` 在专用 integration worktree 内的 integration-branch git allowlist(`git fetch` / `git ls-remote --exit-code --heads origin $INTEGRATION_BRANCH` / `rev-list` / `rev-parse` / `merge-base` / `reset --hard` / `rebase --rebase-merges` / `merge --ff-only|--no-ff` / `push HEAD:$INTEGRATION_BRANCH` / force-with-lease adoption),不得 commit worker diff、create/merge/close PR、开关 issue/PR/label、tag/release,不得作为 generic lifecycle actor。#191 是唯一跨设备 active-controller lease carveout:GitHub/已 push git 面只承载一个全局 `ActiveControllerLease`,允许 read/acquire/renew 专用 lease artifact 并暴露 owner/expiry;禁止 worker diff commit、issue/PR create/merge/close/edit、label mutation、tag/release、per-work claim、host-defined lease scope、跨设备 floor 聚合、daemon ownership matrix、active-active scheduler、generic lifecycle actor。#193 中 issue/PR author.login 与 updatedAt 仅可作为 planning/routing/stale metadata,不得作为 side-effect authorization、per-work owner authority、claim/lease scope 或 takeover permit;issue/PR target 写副作用的跨设备 permit 只来自 #191 ActiveControllerLease。#238 是唯一 closed managed item phase-label reconciliation carveout:active-controller owner 的 checked-in `closed-label-reconciler` 只可对 CLOSED `crnd:lifecycle:managed` issue/PR 做 phase-label reconciliation,移除 phase/cleanup/stuck label 并加 exactly one terminal phase `crnd:phase:merged` 或 `crnd:phase:closed`;禁止 open item mutation、issue/PR create/close/reopen/body/title edit、PR merge、human/triage/milestone/lifecycle label mutation、tag/release、generic lifecycle actor。#322 是唯一 controller-owned release publication carveout:active-controller owner 的 `ReleasePublisher` 只可在 `ReleasePublishPreflight` 验证 `RELEASE_AUTO_ENABLE=true`、fresh release-candidate/release-decision、decision_digest、target_ref、mapped manifest from_version、required checks 全绿后,走同一 publish 主链路:首次发布运行 `python3 .github/scripts/bump_version.py --version `、`git add .version-bump.json `、`git commit -m "Release v"`;already-bumped reentry 仅当 only preflight mismatch 是 mapped manifests 已==`to_version` 且 `git show -s --format=%s HEAD` 证明 HEAD subject 精确为 `Release v` 时跳过这三步。两条路径随后都必须运行 `git rev-parse HEAD`、`git fetch origin HEAD`、`git rev-list --count HEAD..origin/HEAD`、`git push origin HEAD`、通过 `ReleaseRequiredChecksProjection` 读取 `gh api repos//commits//check-runs --paginate --slurp` 或 reentry 的 `gh api repos//commits//check-runs --paginate --slurp` 并确认该 exact fresh SHA required checks 全绿后才运行(或 reentry 时确认该 exact fresh/reentry SHA required checks 全绿后才运行) `gh release create v --target --generate-notes [--prerelease]` 或 reentry 的 `gh release create v --target --generate-notes [--prerelease]`,并写 `.refactor-loop/state/release-publish-result.json`;禁止 public release-publish CLI、workflow tag/release creation、tag target without exact-SHA green checks、`git tag`、force-push、release edit/delete/upload、approval-ticket/emoji gate、issue/PR/label lifecycle、merge/close、generic lifecycle actor。#396 是唯一 unattended wakeup-runner carveout:active-controller owner 的 checked-in `wakeup-runner` 只可消费 `wakeup-plan` 产出的 evidence-bound closed action projection,并对每个 action 重新验证 clean `EXIT=0` source marker、review truth table、OPEN/live GitHub state、#191 owner、release #322 preflight 或 helper-specific precondition 后,机械调用既有 controller helper 或 #396 narrow helper。`wakeup-plan` 是唯一 action projection fact source但不是 standalone authorization source;daemon 不得读 prompt body 决策,不得新增 `ControllerTurnDecision`/controller-turn worker/schema,不得接受 argv/shell/cmd/command_line/commands/env/git/gh/executor/lifecycle_authority/lifecycle_owner/generic command fields,不得把 `.refactor-loop/host.env` 当 host production SSOT。允许动作仅限 spawn codex、named helper `dispatch_design_consensus` through phase9-router deterministic routes、named helper `dispatch_consensus_implementation`、named helper `publish_implementation_output`、named helper `open_release_rollup_pr_from_action`、publish worker output、dispatch reviewers/fix/remote-ci worker、apply triage decision、merge PR under review truth table、close managed item from drop marker、publish release through #322;禁止任意 git/gh 命令、workflow tag/release、label/merge/close outside existing helper or named #396 helper、active-active scheduler、generic lifecycle actor。#403 是唯一大 issue 分解 carveout:active-controller owner 的 checked-in apply helper 只可消费已验证的 `IssueDecompositionPlan`,创建 `crnd:lifecycle:managed` child design issues 并评论父 issue;父 epic 保持 open/tracking,禁止 close/reopen/body-title edit,禁止 daemon/worker 建 issue、public issue factory、wakeup-plan decompose 投影或 generic lifecycle actor。#437 是唯一 skill-private runtime-retention local-GC carveout:active-controller owner 的 checked-in `RuntimeRetention` helper 只可在 host opt-in 后删除 `$REPO_ROOT/.refactor-loop/{logs,prompts,runs}` 内 planner 证明 eligible 的 generated regular files、same-inode compact `$REPO_ROOT/.refactor-loop/.controller-pending-events.log`、以及对 `$REPO_ROOT/.worktrees/` 中 no in-flight/no OPEN issue-or-PR/no dirty/no local-ahead/merged-or-missing-safe 的 stale worktree 运行 `git worktree remove ` 与 `git worktree prune`;允许 GitHub only read OPEN issue/PR/head state;允许 git only read projection plus `git worktree remove`/`git worktree prune`;禁止 `git fetch`、branch deletion、commit/push/reset/rebase/merge/tag、issue/PR create/edit/close/merge、label mutation、tag/release、archive/index durable fact source、generic lifecycle actor。通用授权、escape hatch、宽口径修宪一律视为设计未完成。 - **抽象一旦能被滥用即设计未完成**:允许绕过审查边界、merge gate、CLAUDE.md 修宪门槛的通用机制必须继续收窄。 - **删除优先**:废弃 skill、deprecated wrapper、`*.bak/*.old/*.deprecated` 直接删除,不保留兼容空壳;历史由 git 与 CHANGELOG 保留。 - **变更必须可验证**:行为约束必须落到机械验证手段(behavior test / source-regression test / 段落 lint);仅靠"agent 应该记得"承载的约束视为未落地。 @@ -63,7 +63,7 @@ New principle: 改哲学:单文件 SKILL.md + intra-file anchors 是被认可的 - **maintainer(人)**:产品/战略决策、治理级非可编码变更的授权、罕见手工 merge。 - **agent(controller LLM)**:纯编排;长跑中读 daemon 维护的 counts / state / artifact,不重新自测。 - **agent worker**(被派发的 codex / 其他 CLI 实例):承担所有思考密集工作 —— 实现、验证、修复、review、design solving。每个 worker 在隔离 worktree 内运行。 -- **daemon(`scripts/` 后台)**:可机械、状态确定的 controller 工作的实现载体。Daemon 是经共识授权的 narrow allowlist 例外,默认**不**持 lifecycle authority(不开关 issue/PR/label、不 commit/push/merge/tag/release publish);仅 #53 授权 `integration sync daemon` 在专用 integration worktree 内执行 integration-branch git allowlist;仅 #191 授权 active-controller lease 在专用 pushed ref 上做 singleton owner CAS;仅 #238 授权 active-controller owner 的 checked-in `closed-label-reconciler` 对 CLOSED `crnd:lifecycle:managed` issue/PR 做 terminal phase-label reconciliation;仅 #322 授权 active-controller owner 的 controller-owned `ReleasePublisher` 做 release publication exact allowlist;仅 #396 授权 active-controller owner 的 checked-in `wakeup-runner` 对 `wakeup-plan` closed action projection 做机械 apply;仅 #403 授权 active-controller owner 的 checked-in apply helper 从 validated `IssueDecompositionPlan` 创建 managed child design issues并评论父 issue,`wakeup-plan` 不投射 issue-decomposition apply/status action也不是 #403 read-model owner;判断仍来自已有 worker prompts、meta-judge/review truth table、release preflight、validated plan artifact 和 live GitHub state;daemon 只验证和执行 allowlist。Implement/fix worker 仍不得 commit、push、open PR、merge、close issue/PR。 +- **daemon(`scripts/` 后台)**:可机械、状态确定的 controller 工作的实现载体。Daemon 是经共识授权的 narrow allowlist 例外,默认**不**持 lifecycle authority(不开关 issue/PR/label、不 commit/push/merge/tag/release publish);仅 #53 授权 `integration sync daemon` 在专用 integration worktree 内执行 integration-branch git allowlist;仅 #191 授权 active-controller lease 在专用 pushed ref 上做 singleton owner CAS;仅 #238 授权 active-controller owner 的 checked-in `closed-label-reconciler` 对 CLOSED `crnd:lifecycle:managed` issue/PR 做 terminal phase-label reconciliation;仅 #322 授权 active-controller owner 的 controller-owned `ReleasePublisher` 做 release publication exact allowlist;仅 #396 授权 active-controller owner 的 checked-in `wakeup-runner` 对 `wakeup-plan` closed action projection 做机械 apply;仅 #403 授权 active-controller owner 的 checked-in apply helper 从 validated `IssueDecompositionPlan` 创建 managed child design issues并评论父 issue,`wakeup-plan` 不投射 issue-decomposition apply/status action也不是 #403 read-model owner;仅 #437 授权 active-controller owner 的 checked-in `RuntimeRetention` helper 在 host opt-in 后做 skill-private generated file cleanup、pending-events same-inode compaction、planner-proven stale worktree remove/prune,无 GitHub/git lifecycle authority;判断仍来自已有 worker prompts、meta-judge/review truth table、release preflight、validated plan artifact、runtime-retention plan artifact 和 live GitHub state;daemon 只验证和执行 allowlist。Implement/fix worker 仍不得 commit、push、open PR、merge、close issue/PR。 - **host 项目**:消费 skill 的下游项目。skill **无 host 项目改动权**:不修改 host 的 `.git` 配置 / CI 配置 / policy 文档;只在 `host.env` 暴露的 surface 上工作。host opt-in 缺失或为假时,所有相关 surface 静默 noop(`exit 0` + reason)。 ## 新增 / 修改 skill diff --git a/skills/codex-refactor-loop/SKILL.md b/skills/codex-refactor-loop/SKILL.md index 9364e6fd..6ca92783 100644 --- a/skills/codex-refactor-loop/SKILL.md +++ b/skills/codex-refactor-loop/SKILL.md @@ -106,6 +106,7 @@ This matrix is the only manually maintained host.env contract. `host.env.example | `$UPDATE_CHECK_ENABLE` | optional-noop | update-check probe | `false` | false or empty exits 0 with noop reason and writes disabled update-check state | update-check, restart-daemons | `test_update_check.py`, `test_restart_daemons.py` | | `$UPDATE_CHECK_INTERVAL_SECONDS` | defaulted | update-check probe | `21600` | default to `21600` seconds; fresh local update-check state is reused for manual probes | update-check, concurrency snapshot projection | `test_update_check.py`, `test_concurrency_monitor_snapshot.py` | | `$UPDATE_CHECK_TIMEOUT_SECONDS` | defaulted | update-check probe | `5` | default to `5` seconds for GitHub release/tag reads; failures write unknown state and never block restart | update-check | `test_update_check.py` | +| `$RUNTIME_RETENTION_ENABLE` | optional-noop | RuntimeRetention | `false` | false or empty exits 0 with noop reason; true enables only skill-private generated file cleanup, same-inode pending-events compaction, and planner-proven stale worktree remove/prune | runtime-retention, restart-daemons | `test_runtime_retention.py`, `test_host_env_surface_matrix.py` | | `$RELEASE_AUTO_MIN_MERGES` | defaulted | release-gate | `1` | default to `1` recent merge for stability scoring | release-gate | `test_auto_release_gate.py` | | `$RELEASE_AUTO_MIN_INTERVAL_HOURS` | defaulted | release-gate | `2` | default to `2` hours since last release decision | release-gate | `test_auto_release_gate.py` | | `$RELEASE_ROLLUP_MIN_COMMITS` | defaulted | sync helpers | `1` | default to `1` integration-ahead commit before release-rollup pending event | sync helpers | `test_sync_dev.py` | @@ -301,6 +302,14 @@ Verification is locked by `test_update_check.py`, `test_cli_command_router.py`, ## Named runtime exception - wakeup-runner(per #396) Authorization source: `skills/codex-refactor-loop/authorizations/runtime-exceptions.md#wakeup-runner-396`. `wakeup-runner` is active-controller owner only and consumes only `wakeup-plan` evidence-bound closed action projection. It revalidates #191 owner, clean `EXIT=0` source marker when required, review truth table `reject==0 && approve>=1 && all required reviewers present && all required reviewer heads equal live PR head`, OPEN/live GitHub state, missing/stale per-reviewer head SHA, release #322 preflight, and helper-specific preconditions before mechanically calling existing controller helpers or the named #396 helpers. `wakeup-plan` action `head_sha` is not reviewer-head authority. Consensus→implement projection durable fact source is the consensus judge artifact frontmatter, `## If consensus`, `Implementation owner`, and Implement plan structured fields `scope_paths`, `old_pattern`, `new_principle`, and optional `verification_hints`; parser failure emits no implementation action. Allowed actions are spawn codex, named helper `dispatch_design_consensus` through phase9-router deterministic routes, named helper `dispatch_consensus_implementation`, named helper `publish_implementation_output`, named helper `open_release_rollup_pr_from_action`, publish worker output, dispatch reviewers/fix/remote-ci worker, apply triage decision, merge PR under review truth table, close managed item from drop marker, and publish release through #322. Forbidden: no arbitrary git/gh command, workflow tag/release, prompt-body decision, standalone authorization from `wakeup-plan`, argv/shell/cmd/command_line/commands/env/git/gh/executor/lifecycle_authority/lifecycle_owner/generic command fields, `ControllerTurnDecision`, controller-turn worker, private schema, active-active scheduler, `.refactor-loop/host.env` as host production SSOT, generic lifecycle actor, and arbitrary label/merge/close outside existing helper or named #396 helper. Verification: `test_wakeup_runner.py`, `test_wakeup_runner_review_gate.py`, `test_wakeup_runner_release.py`, `test_wakeup_plan.py`, `test_cli_command_router.py`, `test_runtime_exception_authorization_sources.py`, `test_restart_daemons.py`, and `test_skill_reference_anchors.py`. + +## Named runtime exception - RuntimeRetention(per #437) +Authorization source: `skills/codex-refactor-loop/authorizations/runtime-exceptions.md#runtime-retention-437`. `RuntimeRetention` is the canonical owner for skill-private local runtime cleanup. `consensus-rnd-cli runtime-retention` is the canonical command; `consensus-rnd-cli log-retention` is a one-release compatibility alias to the same handler, same authority, and same tests. + +Narrow allowlist: only when `$RUNTIME_RETENTION_ENABLE=true`, delete planner-eligible generated regular files under `$REPO_ROOT/.refactor-loop/{logs,prompts,runs}` older than the retention TTL, compact `$REPO_ROOT/.refactor-loop/.controller-pending-events.log` in place so Monitor bridges keep the same inode, and remove only `$REPO_ROOT/.worktrees/` entries present in `.refactor-loop/state/runtime-retention-plan.json` with proof fields `no_in_flight`, `no_open_issue_or_pr`, `no_dirty`, `no_local_ahead`, and `merged_or_missing_safe`, after local git read rechecks pass. It may then run `git worktree remove ` and `git worktree prune`. Missing/false opt-in exits 0 with a noop summary. + +Forbidden: no GitHub write or lifecycle authority, no `git fetch`, branch deletion, commit, push, reset, rebase, merge, tag, release, archive/index durable fact source, host config edit, host production SSOT under `.refactor-loop/host.env`, generic lifecycle actor, or worktree cleanup without planner proof. Verification: `test_runtime_retention.py`, `test_log_retention.py`, `test_cli_command_router.py`, `test_restart_daemons.py`, `test_runtime_exception_authorization_sources.py`, and `test_skill_reference_anchors.py`. + ## Large issue decomposition(per #403) Authorization source: `skills/codex-refactor-loop/authorizations/runtime-exceptions.md#issue-decomposition-403`. Large or epic issue decomposition is allowed only after design-consensus/meta-reflector consensus that the source is scope-too-broad or explicitly `decompose`. The only controller-private handoff is an `IssueDecompositionPlan` artifact with exactly `{schema, parent_issue, source_consensus_artifact, children:[{slug,title,scope,non_goals,body_artifact_path}], parent_update:{comment_artifact_path}}`. The plan must not contain lifecycle owner/authority, command, argv, shell, gh, git, close, assignee, milestone, absolute path, or path traversal fields; child bodies must be self-contained GitHub bodies with parent issue, source consensus artifact, scope, non-goals, and final sentinel. @@ -385,7 +394,7 @@ Same heartbeat path/epoch/90s consumers; no new daemon, lifecycle authority, CLA -`skills/codex-refactor-loop/scripts/consensus-rnd-cli restart-daemons` 是 checked-in,host-agnostic restart helper。它维护 static daemon allowlist 的 singleton wrapper + actor-owned heartbeat lease + helper-private launch fingerprint(`concurrency_monitor`, `comment-monitor`, `codex-progress-reporter`, `dev_sync_daemon`, `phase9_router_daemon`, `closed_label_reconciler`)。事实源是 `.refactor-loop/locks/.pid`、`.refactor-loop/heartbeats/.ts`、`.refactor-loop/locks/.fingerprint.json`,以及 helper-private `DaemonProcessInventory`;只有 pid alive、actor-loop heartbeat fresh(`<90s`)、fingerprint current、且同一 static allowlist command 零 duplicate canonical live wrapper 时才 skip,missing/malformed/mismatch fail-closed 并重启对应 wrapper。每次 helper tick 先调用 `consensus-rnd-cli log-retention`,直接删除超过 24h 的 `.refactor-loop/logs/*.log`;不 archive、不索引、不新增非 allowlist daemon。 +`skills/codex-refactor-loop/scripts/consensus-rnd-cli restart-daemons` 是 checked-in,host-agnostic restart helper。它维护 static daemon allowlist 的 singleton wrapper + actor-owned heartbeat lease + helper-private launch fingerprint(`concurrency_monitor`, `comment-monitor`, `codex-progress-reporter`, `dev_sync_daemon`, `phase9_router_daemon`, `closed_label_reconciler`)。事实源是 `.refactor-loop/locks/.pid`、`.refactor-loop/heartbeats/.ts`、`.refactor-loop/locks/.fingerprint.json`,以及 helper-private `DaemonProcessInventory`;只有 pid alive、actor-loop heartbeat fresh(`<90s`)、fingerprint current、且同一 static allowlist command 零 duplicate canonical live wrapper 时才 skip,missing/malformed/mismatch fail-closed 并重启对应 wrapper。每次 helper tick 先调用 canonical `consensus-rnd-cli runtime-retention`;`consensus-rnd-cli log-retention` 只是同 handler/同 authority 的 one-release compatibility alias。 Before starting or repairing any of the seven write daemons, `restart-daemons` acquires or renews the #191 active-controller lease. A non-owner restart writes local status `active_controller=noop:not-owner` and exits 0 without starting, killing, or repairing those daemons. `consensus-rnd-cli daemon-status --json` is the paired read-only daemon-status projection. It reports `running`, `stale`, `dead`, or `not-owner` from the existing static allowlist, helper-private launch fingerprint, pid/heartbeat readers, cached active-controller status, and `DaemonProcessInventory`; it has no public start/stop/restart/reload lifecycle verb. Repair/reload remains restart-daemons. @@ -397,7 +406,7 @@ Uninstall note: remove the cron line or unload/delete the launchd plist; do not `skills/codex-refactor-loop/scripts/consensus-rnd-cli restart-daemons` = Consensus-rnd Phase design-consensus r3 授权的 cron/launchd-only anti-stop helper,不新增 watchdog daemon。 -- **Narrow allowlist**: helper 只 maintain singleton wrapper + actor-owned heartbeat lease + helper-private launch fingerprint for `concurrency_monitor`, `comment-monitor`, `codex-progress-reporter`, `dev_sync_daemon`, `phase9_router_daemon`, `closed_label_reconciler` in the existing static daemon allowlist;heartbeat 是 actor-loop progress lease,不是 wrapper sidecar liveness;daemon actor after tick / caught exception / lease sleep renews it;fingerprint artifact `.refactor-loop/locks/.fingerprint.json` 只记录 daemon name、resolved command、CLI entrypoint hash、Python package tree hash 和文件计数,只用于 restart skip eligibility;`DaemonProcessInventory` 只在 helper 内部枚举 same resolved static allowlist command 的 canonical live wrapper,发现 duplicate canonical live wrapper 时 duplicate canonical wrappers fail closed:先 terminate 多余实例并等待下一 tick,绝不在 duplicate 存在时 spawn;并顺手运行 `consensus-rnd-cli log-retention` 对 24h+ `.refactor-loop/logs/*.log` direct rm;不 spawn codex / commit / push / merge / label / archive。 +- **Narrow allowlist**: helper 只 maintain singleton wrapper + actor-owned heartbeat lease + helper-private launch fingerprint for `concurrency_monitor`, `comment-monitor`, `codex-progress-reporter`, `dev_sync_daemon`, `phase9_router_daemon`, `closed_label_reconciler` in the existing static daemon allowlist;heartbeat 是 actor-loop progress lease,不是 wrapper sidecar liveness;daemon actor after tick / caught exception / lease sleep renews it;fingerprint artifact `.refactor-loop/locks/.fingerprint.json` 只记录 daemon name、resolved command、CLI entrypoint hash、Python package tree hash 和文件计数,只用于 restart skip eligibility;`DaemonProcessInventory` 只在 helper 内部枚举 same resolved static allowlist command 的 canonical live wrapper,发现 duplicate canonical live wrapper 时 duplicate canonical wrappers fail closed:先 terminate 多余实例并等待下一 tick,绝不在 duplicate 存在时 spawn;并顺手运行 canonical `consensus-rnd-cli runtime-retention`;不 spawn codex / commit / push / merge / label / archive。 - **Read-only status projection**: `consensus-rnd-cli daemon-status --json` mirrors the same static allowlist and helper-private pid/heartbeat/fingerprint/inventory facts plus cached active-controller status. It is read-only status only, has no public start/stop/restart/reload lifecycle verb, and repair/reload remains restart-daemons. - **Host-agnostic**: 只使用 `$REPO_ROOT` 相对路径和 `` self-location;无 host fact hardcode。 - **No lifecycle authority**: 不开关 issue/PR,不打 label,不 commit/push/merge/tag/release;controller wakeup `STALE_CONTROLLER` 事件仅 alert。 @@ -832,7 +841,7 @@ Operational details live in [language policy details](#language-policy-details); - [scripts/consensus-rnd-cli check-project-rules](scripts/consensus-rnd-cli check-project-rules) — read-only Consensus-rnd Phase bootstrap fixed-point probe; writes patch artifact only. - [scripts/consensus-rnd-cli concurrency](scripts/consensus-rnd-cli concurrency) — no-gap sentinel daemon. - [scripts/consensus-rnd-cli restart-daemons](scripts/consensus-rnd-cli restart-daemons) — cron/launchd anti-stop helper for existing daemon wrappers. -- [scripts/consensus-rnd-cli log-retention](scripts/consensus-rnd-cli log-retention) — daemonless 24h direct-rm helper for `.refactor-loop/logs/*.log`. +- [scripts/consensus-rnd-cli runtime-retention](scripts/consensus-rnd-cli runtime-retention) — canonical RuntimeRetention helper for host-opt-in skill-private generated file cleanup, pending-events compaction, and planner-proven stale worktree remove/prune; `log-retention` is a one-release compatibility alias. - [scripts/consensus-rnd-cli progress-reporter](scripts/consensus-rnd-cli progress-reporter) — progress comment daemon. - [scripts/consensus-rnd-cli comment-monitor](scripts/consensus-rnd-cli comment-monitor) — maintainer comment monitor. - [scripts/consensus-rnd-cli dev-sync](scripts/consensus-rnd-cli dev-sync) — integration sync daemon. @@ -1178,7 +1187,7 @@ nohup bash -c 'source "${CONSENSUS_RND_HOST_ENV:-.refactor-loop/host.env}" && ex Integration sync uses integration sync operation artifacts; the daemon writes `.refactor-loop/runs/integration-sync-operation--.json` and records `.refactor-loop/runs/integration-sync-executions/.(applied|rejected).json` after live-state validation. **7 个长跑 daemon 全部要起**(监控面 = 这 7 个):`consensus-rnd-cli concurrency`(60s codex 并发)、`consensus-rnd-cli progress-reporter`(600s 进度回贴)、`consensus-rnd-cli comment-monitor`(30s maintainer 评论 eyes-react)、`consensus-rnd-cli dev-sync`(600s integration sync operation executor)、`consensus-rnd-cli phase9-router`(30s narrow Consensus-rnd Phase design-consensus deterministic routing)、`consensus-rnd-cli closed-label-reconciler`(1800s closed managed item terminal phase-label reconciliation)、`consensus-rnd-cli wakeup-runner`(60s #396 closed action projection apply)。 -`consensus-rnd-cli restart-daemons` also runs daemonless log retention before daemon freshness checks. `consensus-rnd-cli log-retention` has no lifecycle authority: it reads the host-owned `host.env`, targets only `$REPO_ROOT/.refactor-loop/logs/*.log`, and directly removes regular log files older than 24h. It must not create archive/index state, scan or delete `.refactor-loop/runs/` or prompts, call GitHub, run git, spawn codex, or become a daemon. Verification lives in `test_log_retention.py`. +`consensus-rnd-cli restart-daemons` also runs daemonless RuntimeRetention before daemon freshness checks. `consensus-rnd-cli runtime-retention` has no GitHub lifecycle authority and is disabled unless `$RUNTIME_RETENTION_ENABLE=true`. When enabled, it targets only skill-private runtime paths: generated regular files under `$REPO_ROOT/.refactor-loop/{logs,prompts,runs}`, same-inode compaction of `$REPO_ROOT/.refactor-loop/.controller-pending-events.log`, and planner-proven stale `$REPO_ROOT/.worktrees/` entries from `.refactor-loop/state/runtime-retention-plan.json` after local git read rechecks. It may run only `git worktree remove ` and `git worktree prune`; it must not create archive/index state, call GitHub, run `git fetch`, delete branches, commit, push, reset, rebase, merge, tag, release, spawn codex, or become a daemon. `consensus-rnd-cli log-retention` is a one-release compatibility alias to the same handler and authority. Verification lives in `test_runtime_retention.py` and `test_log_retention.py`. -- allowed: cron or launchd helper maintains singleton wrappers, actor-owned heartbeat leases, helper-private launch fingerprints at `.refactor-loop/locks/.fingerprint.json`, and helper-private `DaemonProcessInventory` for the existing static daemon allowlist; pid alive plus fresh heartbeat plus current fingerprint plus zero duplicate canonical live wrapper for the same resolved static allowlist command is the only skip condition, missing, malformed, or mismatched fingerprint data fails closed to restart, and duplicate canonical wrappers fail closed to repair/reconcile before restart; runs 24h log retention. `consensus-rnd-cli daemon-status --json` is a read-only daemon-status projection over the same static allowlist, pid/heartbeat/fingerprint readers, cached active-controller status, and `DaemonProcessInventory`; repair/reload remains restart-daemons. +- allowed: cron or launchd helper maintains singleton wrappers, actor-owned heartbeat leases, helper-private launch fingerprints at `.refactor-loop/locks/.fingerprint.json`, and helper-private `DaemonProcessInventory` for the existing static daemon allowlist; pid alive plus fresh heartbeat plus current fingerprint plus zero duplicate canonical live wrapper for the same resolved static allowlist command is the only skip condition, missing, malformed, or mismatched fingerprint data fails closed to restart, and duplicate canonical wrappers fail closed to repair/reconcile before restart; runs canonical RuntimeRetention before daemon freshness checks. `consensus-rnd-cli daemon-status --json` is a read-only daemon-status projection over the same static allowlist, pid/heartbeat/fingerprint readers, cached active-controller status, and `DaemonProcessInventory`; repair/reload remains restart-daemons. - forbidden: no host-defined daemon registry, generic process supervisor, GitHub/git lifecycle authority, codex spawn, commit, push, merge, label, archive, index, new daemon, issue lifecycle, PR lifecycle, tag, release, wrapper sidecar heartbeat writer, public start/stop/restart/reload lifecycle verb, or generic lifecycle authority. -- verification: `test_restart_daemons.py`, `test_anti_stop_restart_helper_contract.py`, `test_cli_command_router.py`, `test_log_retention.py`, `test_runtime_exception_authorization_sources.py` +- verification: `test_restart_daemons.py`, `test_anti_stop_restart_helper_contract.py`, `test_cli_command_router.py`, `test_runtime_retention.py`, `test_runtime_exception_authorization_sources.py` - no_new_runtime_authority: This mirror only replaces the missing ignored judge-log authorization path. + +## runtime-retention-437 + +- surface: `consensus-rnd-cli runtime-retention` +- source_issue: `#437` +- source_round: `r4` +- source_marker: `META_JUDGE_DONE:consensus:structural:choose canonical runtime-retention owner with one-release log-retention alias and #437 narrow local-GC carveout` +- skill_anchor: `#named-runtime-exception--runtime-retentionper-437` +- allowed: active-controller owner only; host opt-in is `$RUNTIME_RETENTION_ENABLE=true`; `RuntimeRetention` is the only canonical owner; `consensus-rnd-cli log-retention` is a one-release compatibility alias with the same handler and authority; delete only planner-eligible generated regular files under `$REPO_ROOT/.refactor-loop/{logs,prompts,runs}`; same-inode compact `$REPO_ROOT/.refactor-loop/.controller-pending-events.log`; consume `.refactor-loop/state/runtime-retention-plan.json` as the planner proof for stale `$REPO_ROOT/.worktrees/` entries with `no_in_flight`, `no_open_issue_or_pr`, `no_dirty`, `no_local_ahead`, and `merged_or_missing_safe`, recheck local git read projections, then run only `git worktree remove ` and `git worktree prune`. +- forbidden: no GitHub write, no issue/PR create/edit/close/merge, no label mutation, no tag/release, no `git fetch`, no branch deletion, no commit, no push, no reset, no rebase, no merge, no tag, no archive/index durable fact source, no host config edit, no `.refactor-loop/host.env` as host production SSOT, no worktree cleanup without planner proof, no public lifecycle command, no daemon ownership expansion, and no generic lifecycle actor. +- verification: `test_runtime_retention.py`, `test_log_retention.py`, `test_cli_command_router.py`, `test_restart_daemons.py`, `test_runtime_exception_authorization_sources.py`, `test_skill_reference_anchors.py`, `test_anti_stop_restart_helper_contract.py` +- no_new_runtime_authority: This mirror narrows local GC to RuntimeRetention only; it does not authorize GitHub lifecycle, git branch lifecycle, release lifecycle, generic cleanup, host production config ownership, or a second retention owner. + ## phase9-router-open-state-gate-229 diff --git a/skills/codex-refactor-loop/host.env.example b/skills/codex-refactor-loop/host.env.example index f033682b..6ff0623f 100644 --- a/skills/codex-refactor-loop/host.env.example +++ b/skills/codex-refactor-loop/host.env.example @@ -74,6 +74,9 @@ export UPDATE_CHECK_INTERVAL_SECONDS="21600" # Default: GitHub release/tag read timeout seconds. export UPDATE_CHECK_TIMEOUT_SECONDS="5" +# Default/noop: RuntimeRetention local cleanup opt-in. False/empty does nothing. +export RUNTIME_RETENTION_ENABLE="false" + # Default: minimum recent merges required by auto_release_gate stability. export RELEASE_AUTO_MIN_MERGES="1" diff --git a/skills/codex-refactor-loop/scripts/codex_refactor_loop/cli.py b/skills/codex-refactor-loop/scripts/codex_refactor_loop/cli.py index f946939e..d8a4c930 100644 --- a/skills/codex-refactor-loop/scripts/codex_refactor_loop/cli.py +++ b/skills/codex-refactor-loop/scripts/codex_refactor_loop/cli.py @@ -24,7 +24,7 @@ from .release.gate import main as release_gate_main from .release.required_checks import main as release_required_checks_main from .restart import main as restart_main -from .retention import main as retention_main +from .runtime_retention import main as runtime_retention_main from .sync.dev import main as dev_sync_main from .phase9.router import main as phase9_router_main from .update_check import main as update_check_main @@ -81,7 +81,7 @@ class CommandSpec: "restart-daemons": CommandSpec( restart_main, "run the Python daemon restart helper", - ("spawn-daemon", "write-state", "delete-log"), + ("spawn-daemon", "write-state", "delete-runtime"), ), "daemon-status": CommandSpec( daemon_status_main, @@ -167,7 +167,16 @@ class CommandSpec: ("read-source", "read-state"), ), "check-manifest": CommandSpec(manifest_main, "run manifest version sync check", ("read-source",)), - "log-retention": CommandSpec(retention_main, "run daemonless log retention", ("delete-log",)), + "runtime-retention": CommandSpec( + runtime_retention_main, + "run canonical RuntimeRetention for skill-private generated artifacts", + ("delete-runtime", "git-worktree"), + ), + "log-retention": CommandSpec( + runtime_retention_main, + "one-release compatibility alias for runtime-retention", + ("delete-runtime", "git-worktree"), + ), "check-project-rules": CommandSpec( project_rules.main, "check host project rules fixed points and write patch artifact when needed", diff --git a/skills/codex-refactor-loop/scripts/codex_refactor_loop/restart.py b/skills/codex-refactor-loop/scripts/codex_refactor_loop/restart.py index 80855016..2a987773 100644 --- a/skills/codex-refactor-loop/scripts/codex_refactor_loop/restart.py +++ b/skills/codex-refactor-loop/scripts/codex_refactor_loop/restart.py @@ -18,7 +18,7 @@ from .active_controller import require_active_controller, write_active_controller_status from .context import LoopContext, LoopContextError from .gh_accounting import accounting_env -from .retention import retain_logs +from .runtime_retention import retain_runtime, runtime_retention_enabled from .update_check import maybe_run_update_check @@ -260,7 +260,7 @@ def run(self) -> int: return 0 self._acquire_restart_lock() try: - self._run_log_retention() + self._run_runtime_retention() for name, command in DAEMON_COMMANDS: self.start_daemon(name, command) finally: @@ -342,14 +342,19 @@ def _prepare_dirs(self) -> None: for path in (self.ctx.paths.refactor_loop / "locks", self.ctx.paths.heartbeats, self.ctx.paths.logs): path.mkdir(parents=True, exist_ok=True) - def _run_log_retention(self) -> None: + def _run_runtime_retention(self) -> None: try: - deleted, kept, target, missing = retain_logs(self.ctx.repo_root) + result = retain_runtime(self.ctx.repo_root, enabled=runtime_retention_enabled(self.ctx)) except Exception: - self._log("log_retention warning: helper failed; continuing daemon restart") + self._log("runtime_retention warning: helper failed; continuing daemon restart") return - suffix = " missing=true" if missing else "" - self._log(f"log_retention: ttl_hours=24 deleted={deleted} kept={kept} target={target}{suffix}") + suffix = " missing=true" if result.missing else "" + self._log( + "runtime_retention: " + f"enabled={str(result.enabled).lower()} ttl_hours=24 deleted={result.deleted} kept={result.kept} " + f"compacted_events={str(result.compacted_events).lower()} removed_worktrees={result.removed_worktrees} " + f"pruned_worktrees={str(result.pruned_worktrees).lower()} target={result.target}{suffix}" + ) def _run_update_check(self) -> None: try: diff --git a/skills/codex-refactor-loop/scripts/codex_refactor_loop/retention.py b/skills/codex-refactor-loop/scripts/codex_refactor_loop/retention.py index 8500cf79..542f2919 100644 --- a/skills/codex-refactor-loop/scripts/codex_refactor_loop/retention.py +++ b/skills/codex-refactor-loop/scripts/codex_refactor_loop/retention.py @@ -1,62 +1,14 @@ -"""Daemonless 24 hour log retention for .refactor-loop/logs.""" +"""Compatibility alias for RuntimeRetention.""" from __future__ import annotations -import os -import sys -import time -from pathlib import Path from typing import Sequence -from .context import LoopContext, LoopContextError - - -RETENTION_TTL_HOURS = 24 - - -def retain_logs(repo_root: Path, *, now: float | None = None) -> tuple[int, int, Path, bool]: - repo_real = repo_root.resolve() - log_dir = repo_real / ".refactor-loop" / "logs" - if log_dir != repo_real / ".refactor-loop" / "logs": - raise RuntimeError(f"log retention target escaped .refactor-loop/logs: {log_dir}") - if not log_dir.is_dir(): - return 0, 0, log_dir, True - cutoff = int(now if now is not None else time.time()) - RETENTION_TTL_HOURS * 60 * 60 - deleted = 0 - kept = 0 - for path in log_dir.iterdir(): - try: - if path.is_symlink() or not path.is_file(): - kept += 1 - continue - if path.suffix != ".log": - kept += 1 - continue - if int(path.stat().st_mtime) < cutoff: - path.unlink(missing_ok=True) - deleted += 1 - else: - kept += 1 - except OSError: - kept += 1 - return deleted, kept, log_dir, False +from .runtime_retention import main as runtime_retention_main def main(argv: Sequence[str] | None = None) -> int: - del argv - try: - ctx = LoopContext.load(cwd=os.getcwd()) - except LoopContextError as exc: - sys.stderr.write(f"FATAL: {exc}\n") - return 2 - try: - deleted, kept, target, missing = retain_logs(ctx.repo_root) - except RuntimeError as exc: - sys.stderr.write(f"FATAL: {exc}\n") - return 2 - suffix = " missing=true" if missing else "" - print(f"log_retention: ttl_hours={RETENTION_TTL_HOURS} deleted={deleted} kept={kept} target={target}{suffix}") - return 0 + return runtime_retention_main(argv) if __name__ == "__main__": diff --git a/skills/codex-refactor-loop/scripts/codex_refactor_loop/runtime_retention.py b/skills/codex-refactor-loop/scripts/codex_refactor_loop/runtime_retention.py new file mode 100644 index 00000000..a7eabc7b --- /dev/null +++ b/skills/codex-refactor-loop/scripts/codex_refactor_loop/runtime_retention.py @@ -0,0 +1,230 @@ +"""Runtime retention for skill-private generated artifacts.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Sequence + +from .active_controller import require_active_controller +from .context import LoopContext, LoopContextError + + +RETENTION_TTL_HOURS = 24 +PENDING_EVENTS_MAX_LINES = 2000 +RETENTION_PLAN_PATH = Path(".refactor-loop") / "state" / "runtime-retention-plan.json" +GENERATED_DIRS = ("logs", "prompts", "runs") +GENERATED_SUFFIXES = (".json", ".log", ".md", ".txt") + + +@dataclass(frozen=True) +class RuntimeRetentionResult: + enabled: bool + deleted: int + kept: int + compacted_events: bool + removed_worktrees: int + pruned_worktrees: bool + target: Path + missing: bool + + +def runtime_retention_enabled(ctx: LoopContext) -> bool: + value = (ctx.host_env.get("RUNTIME_RETENTION_ENABLE") or os.environ.get("RUNTIME_RETENTION_ENABLE") or "").strip().lower() + return value == "true" + + +def retain_runtime( + repo_root: Path, + *, + enabled: bool = False, + now: float | None = None, + command_runner: Callable[[Sequence[str]], subprocess.CompletedProcess[str]] | None = None, +) -> RuntimeRetentionResult: + repo_real = repo_root.resolve() + refactor_loop = repo_real / ".refactor-loop" + if not enabled: + return RuntimeRetentionResult(False, 0, 0, False, 0, False, refactor_loop, not refactor_loop.is_dir()) + if not refactor_loop.is_dir(): + return RuntimeRetentionResult(True, 0, 0, False, 0, False, refactor_loop, True) + + cutoff = int(now if now is not None else time.time()) - RETENTION_TTL_HOURS * 60 * 60 + deleted, kept = _delete_generated_files(repo_real, cutoff) + compacted = _compact_pending_events(refactor_loop / ".controller-pending-events.log") + removed = _remove_planner_stale_worktrees(repo_real, command_runner=command_runner or _run_git) + pruned = False + if removed: + prune = (command_runner or _run_git)(["git", "-C", str(repo_real), "worktree", "prune"]) + pruned = prune.returncode == 0 + return RuntimeRetentionResult(True, deleted, kept, compacted, removed, pruned, refactor_loop, False) + + +def _delete_generated_files(repo_root: Path, cutoff: int) -> tuple[int, int]: + refactor_loop = repo_root / ".refactor-loop" + deleted = 0 + kept = 0 + for dirname in GENERATED_DIRS: + target_dir = (refactor_loop / dirname).resolve() + try: + target_dir.relative_to(refactor_loop.resolve()) + except ValueError as exc: + raise RuntimeError(f"runtime retention target escaped .refactor-loop: {target_dir}") from exc + if not target_dir.is_dir(): + continue + for path in target_dir.iterdir(): + try: + if path.is_symlink() or not path.is_file(): + kept += 1 + continue + if path.suffix not in GENERATED_SUFFIXES: + kept += 1 + continue + if int(path.stat().st_mtime) < cutoff: + path.unlink(missing_ok=True) + deleted += 1 + else: + kept += 1 + except OSError: + kept += 1 + return deleted, kept + + +def _compact_pending_events(path: Path) -> bool: + try: + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return False + if len(lines) <= PENDING_EVENTS_MAX_LINES: + return False + tail = lines[-PENDING_EVENTS_MAX_LINES:] + # Keep the same-inode file watched by controller Monitor bridges. + with path.open("r+", encoding="utf-8") as handle: + handle.seek(0) + handle.write("\n".join(tail) + "\n") + handle.truncate() + return True + + +def _remove_planner_stale_worktrees( + repo_root: Path, + *, + command_runner: Callable[[Sequence[str]], subprocess.CompletedProcess[str]], +) -> int: + plan = _read_retention_plan(repo_root / RETENTION_PLAN_PATH) + if not plan: + return 0 + removed = 0 + for item in plan: + path = _eligible_worktree_path(repo_root, item) + if path is None: + continue + if not _git_verification_passes(repo_root, path, command_runner=command_runner): + continue + result = command_runner(["git", "-C", str(repo_root), "worktree", "remove", str(path)]) + if result.returncode == 0: + removed += 1 + return removed + + +def _read_retention_plan(path: Path) -> list[dict[str, Any]]: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return [] + if not isinstance(raw, dict): + return [] + if raw.get("kind") != "RuntimeRetentionPlan": + return [] + worktrees = raw.get("stale_worktrees") + if not isinstance(worktrees, list): + return [] + return [item for item in worktrees if isinstance(item, dict)] + + +def _eligible_worktree_path(repo_root: Path, item: dict[str, Any]) -> Path | None: + if item.get("eligible") is not True: + return None + proof = item.get("proof") + if not isinstance(proof, dict): + return None + required_truths = ( + "no_in_flight", + "no_open_issue_or_pr", + "no_dirty", + "no_local_ahead", + "merged_or_missing_safe", + ) + if any(proof.get(key) is not True for key in required_truths): + return None + raw_path = item.get("path") + if not isinstance(raw_path, str) or not raw_path: + return None + rel = Path(raw_path) + if rel.is_absolute() or ".." in rel.parts or len(rel.parts) != 2 or rel.parts[0] != ".worktrees": + return None + path = (repo_root / rel).resolve() + try: + path.relative_to((repo_root / ".worktrees").resolve()) + except ValueError: + return None + return path + + +def _git_verification_passes( + repo_root: Path, + path: Path, + *, + command_runner: Callable[[Sequence[str]], subprocess.CompletedProcess[str]], +) -> bool: + if not path.is_dir(): + return False + dirty = command_runner(["git", "-C", str(path), "status", "--porcelain"]) + if dirty.returncode != 0 or dirty.stdout.strip(): + return False + ahead = command_runner(["git", "-C", str(path), "rev-list", "--count", "@{upstream}..HEAD"]) + if ahead.returncode == 0 and ahead.stdout.strip() not in ("", "0"): + return False + return True + + +def _run_git(command: Sequence[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(list(command), capture_output=True, text=True, check=False) + + +def _summary(result: RuntimeRetentionResult) -> str: + suffix = " missing=true" if result.missing else "" + return ( + f"runtime_retention: enabled={str(result.enabled).lower()} ttl_hours={RETENTION_TTL_HOURS} " + f"deleted={result.deleted} kept={result.kept} compacted_events={str(result.compacted_events).lower()} " + f"removed_worktrees={result.removed_worktrees} pruned_worktrees={str(result.pruned_worktrees).lower()} " + f"target={result.target}{suffix}" + ) + + +def main(argv: Sequence[str] | None = None) -> int: + del argv + try: + ctx = LoopContext.load(cwd=os.getcwd()) + except LoopContextError as exc: + sys.stderr.write(f"FATAL: {exc}\n") + return 2 + decision = require_active_controller(ctx, "runtime-retention") + if not decision.allowed: + print(f"runtime_retention: enabled=false active_controller=noop:{decision.status} owner={decision.owner_device}") + return 0 + try: + result = retain_runtime(ctx.repo_root, enabled=runtime_retention_enabled(ctx)) + except RuntimeError as exc: + sys.stderr.write(f"FATAL: {exc}\n") + return 2 + print(_summary(result)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/codex-refactor-loop/scripts/test_anti_stop_restart_helper_contract.py b/skills/codex-refactor-loop/scripts/test_anti_stop_restart_helper_contract.py index 142d1def..f90552d3 100644 --- a/skills/codex-refactor-loop/scripts/test_anti_stop_restart_helper_contract.py +++ b/skills/codex-refactor-loop/scripts/test_anti_stop_restart_helper_contract.py @@ -40,6 +40,7 @@ def test_skill_contains_named_exception_contract(self) -> None: "No lifecycle authority", "STALE_CONTROLLER", "$REPO_ROOT", + "consensus-rnd-cli runtime-retention", ): with self.subTest(needle=needle): self.assertIn(needle, self.skill) @@ -118,6 +119,7 @@ def test_runtime_exception_mirror_mentions_launch_fingerprint_contract(self) -> "same resolved static allowlist command", "read-only daemon-status projection", "repair/reload remains restart-daemons", + "runs canonical RuntimeRetention before daemon freshness checks", "no host-defined daemon registry", "generic process supervisor", "GitHub/git lifecycle authority", diff --git a/skills/codex-refactor-loop/scripts/test_cli_command_router.py b/skills/codex-refactor-loop/scripts/test_cli_command_router.py index ed61bd5f..ca5276cc 100644 --- a/skills/codex-refactor-loop/scripts/test_cli_command_router.py +++ b/skills/codex-refactor-loop/scripts/test_cli_command_router.py @@ -22,7 +22,7 @@ CLI = SCRIPT_DIR / "consensus-rnd-cli" ALL_AUTHORITY_TOKENS = { - "delete-log", + "delete-runtime", "gh-close", "gh-close-linked", "gh-comment", @@ -73,6 +73,7 @@ "progress-reporter", "release-gate", "restart-daemons", + "runtime-retention", "wakeup-runner", } @@ -91,6 +92,8 @@ DAEMON_LIFECYCLE_CARVEOUTS = { "dev-sync": {"git-fetch", "git-worktree", "git-merge", "git-push", "git-rebase", "git-reset"}, + "log-retention": {"git-worktree"}, + "runtime-retention": {"git-worktree"}, "wakeup-runner": {"git-commit-worker-output", "git-push", "gh-open", "gh-merge", "gh-close-linked", "gh-label-owned"}, } @@ -125,6 +128,7 @@ def test_each_public_operation_is_registered(self) -> None: "concurrency", "dev-sync", "log-retention", + "runtime-retention", "spawn-codex", "peek", "pr-checks", @@ -368,6 +372,16 @@ def test_update_check_declares_exact_notify_only_authority(self) -> None: self.assertEqual(("read-source", "read-gh", "write-state"), COMMANDS["update-check"].authority) self.assertFalse(set(COMMANDS["update-check"].authority) & LIFECYCLE_TOKENS) + def test_runtime_retention_is_canonical_and_log_retention_is_one_release_alias(self) -> None: + self.assertEqual(("delete-runtime", "git-worktree"), COMMANDS["runtime-retention"].authority) + self.assertEqual(COMMANDS["runtime-retention"].handler, COMMANDS["log-retention"].handler) + self.assertEqual(COMMANDS["runtime-retention"].authority, COMMANDS["log-retention"].authority) + self.assertIn("canonical RuntimeRetention", COMMANDS["runtime-retention"].description) + self.assertIn("one-release compatibility alias", COMMANDS["log-retention"].description) + for forbidden in ("read-gh", "gh-close", "gh-edit", "gh-label", "gh-merge", "gh-open", "git-fetch", "git-push", "git-merge", "git-reset", "git-rebase"): + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden, COMMANDS["runtime-retention"].authority) + def test_public_commands_expose_no_generic_lifecycle_authority_tokens(self) -> None: for name, spec in COMMANDS.items(): with self.subTest(command=name): diff --git a/skills/codex-refactor-loop/scripts/test_comment_monitor.py b/skills/codex-refactor-loop/scripts/test_comment_monitor.py index 3961b37a..ef20c2f5 100644 --- a/skills/codex-refactor-loop/scripts/test_comment_monitor.py +++ b/skills/codex-refactor-loop/scripts/test_comment_monitor.py @@ -54,9 +54,11 @@ def test_generic_env_overrides_do_not_change_default_state_file_or_interval(self with mock.patch.dict( os.environ, { + "PATH": os.environ.get("PATH", ""), "STATE_FILE": str(override_root / "state.json"), "INTERVAL": "1", }, + clear=True, ): monitor = CommentMonitor(self.ctx) diff --git a/skills/codex-refactor-loop/scripts/test_host_env_surface_matrix.py b/skills/codex-refactor-loop/scripts/test_host_env_surface_matrix.py index 85b1f884..14d72314 100644 --- a/skills/codex-refactor-loop/scripts/test_host_env_surface_matrix.py +++ b/skills/codex-refactor-loop/scripts/test_host_env_surface_matrix.py @@ -161,6 +161,7 @@ def test_defaults_and_missing_behaviors_match(self) -> None: "UPDATE_CHECK_ENABLE": ("false", "disabled update-check state"), "UPDATE_CHECK_INTERVAL_SECONDS": ("21600", "fresh local update-check state"), "UPDATE_CHECK_TIMEOUT_SECONDS": ("5", "failures write unknown state"), + "RUNTIME_RETENTION_ENABLE": ("false", "same-inode pending-events compaction"), "CODEX_FLOOR": ("5", "hard min `2`"), "ACTIVE_CONTROLLER_DEVICE_ID": ("", "single-device local-owner noop"), "ACTIVE_CONTROLLER_TTL_SECONDS": ("1800", "expired lease may be acquired by another device"), @@ -186,6 +187,9 @@ def test_defaults_and_missing_behaviors_match(self) -> None: self.assertEqual("optional-noop", self.rows["ACTIVE_CONTROLLER_DEVICE_ID"]["Category"]) self.assertEqual("optional-noop", self.rows["UPDATE_CHECK_ENABLE"]["Category"]) + self.assertEqual("optional-noop", self.rows["RUNTIME_RETENTION_ENABLE"]["Category"]) + self.assertEqual("RuntimeRetention", self.rows["RUNTIME_RETENTION_ENABLE"]["Owner"]) + self.assertIn("planner-proven stale worktree remove/prune", self.rows["RUNTIME_RETENTION_ENABLE"]["Missing/empty behavior"]) self.assertEqual("defaulted", self.rows["UPDATE_CHECK_INTERVAL_SECONDS"]["Category"]) self.assertEqual("defaulted", self.rows["UPDATE_CHECK_TIMEOUT_SECONDS"]["Category"]) self.assertEqual("defaulted", self.rows["ACTIVE_CONTROLLER_TTL_SECONDS"]["Category"]) diff --git a/skills/codex-refactor-loop/scripts/test_log_retention.py b/skills/codex-refactor-loop/scripts/test_log_retention.py index fffed9fe..f2bc7f4f 100644 --- a/skills/codex-refactor-loop/scripts/test_log_retention.py +++ b/skills/codex-refactor-loop/scripts/test_log_retention.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Behavior and source-regression tests for consensus-rnd-cli log-retention.""" +"""Compatibility tests for consensus-rnd-cli log-retention.""" from __future__ import annotations @@ -8,116 +8,54 @@ import subprocess import sys import tempfile -import time import unittest from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent -SKILL_ROOT = SCRIPT_DIR.parent CLI = SCRIPT_DIR / "consensus-rnd-cli" sys.path.insert(0, str(SCRIPT_DIR)) -from codex_refactor_loop.retention import RETENTION_TTL_HOURS, retain_logs +from codex_refactor_loop import runtime_retention +from codex_refactor_loop.cli import COMMANDS -class LogRetentionBehaviorTests(unittest.TestCase): +class LogRetentionCompatibilityTests(unittest.TestCase): def setUp(self) -> None: - self.tmp_root = Path(tempfile.mkdtemp(prefix="log-retention-test-")) + self.tmp_root = Path(tempfile.mkdtemp(prefix="log-retention-alias-test-")) self.repo = self.tmp_root / "repo" - self.logs = self.repo / ".refactor-loop" / "logs" - self.logs.mkdir(parents=True) - (self.repo / ".refactor-loop" / "host.env").write_text(f'export REPO_ROOT="{self.repo}"\n', encoding="utf-8") + (self.repo / ".refactor-loop").mkdir(parents=True) + (self.repo / ".refactor-loop" / "host.env").write_text( + f'export REPO_ROOT="{self.repo}"\nexport RUNTIME_RETENTION_ENABLE="false"\n', + encoding="utf-8", + ) def tearDown(self) -> None: shutil.rmtree(self.tmp_root, ignore_errors=True) - def write_file(self, rel: str, text: str, age_hours: float) -> Path: - path = self.repo / rel - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - ts = time.time() - age_hours * 60 * 60 - os.utime(path, (ts, ts)) - return path - - def run_cli(self, *, cwd: Path | None = None, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: - run_env = os.environ.copy() - run_env.pop("REPO_ROOT", None) - if env: - run_env.update(env) - return subprocess.run( + def test_log_retention_cli_is_runtime_retention_alias(self) -> None: + result = subprocess.run( [sys.executable, str(CLI), "log-retention"], - cwd=cwd or self.repo, - env=run_env, + cwd=self.repo, + env={"PATH": os.environ.get("PATH", ""), "PYTHONPATH": os.environ.get("PYTHONPATH", "")}, capture_output=True, text=True, check=False, ) - def test_deletes_only_log_files_older_than_24h(self) -> None: - old_log = self.write_file(".refactor-loop/logs/old.log", "done\nEXIT=0\n", 25) - young_log = self.write_file(".refactor-loop/logs/young.log", "done\nEXIT=0\n", 1) - old_non_log = self.write_file(".refactor-loop/logs/old.txt", "keep\n", 25) - run_artifact = self.write_file(".refactor-loop/runs/old.log", "keep\n", 25) - deleted, kept, target, missing = retain_logs(self.repo) - self.assertEqual((deleted, missing), (1, False)) - self.assertEqual(target.resolve(), self.logs.resolve()) - self.assertGreaterEqual(kept, 2) - self.assertFalse(old_log.exists()) - self.assertTrue(young_log.exists()) - self.assertTrue(old_non_log.exists()) - self.assertTrue(run_artifact.exists()) - - def test_cli_uses_host_env_and_reports_summary(self) -> None: - self.write_file(".refactor-loop/logs/old.log", "done\nEXIT=0\n", 25) - result = self.run_cli() self.assertEqual(0, result.returncode, result.stderr) - self.assertIn("log_retention: ttl_hours=24 deleted=1", result.stdout) - - def test_refuses_without_repo_root_or_host_env(self) -> None: - isolated = self.tmp_root / "isolated" - isolated.mkdir() - result = self.run_cli(cwd=isolated) - self.assertEqual(2, result.returncode) - self.assertIn("REPO_ROOT is unset", result.stderr) + self.assertIn("runtime_retention: enabled=false", result.stdout) + self.assertEqual(COMMANDS["log-retention"].handler, COMMANDS["runtime-retention"].handler) + self.assertEqual(24, runtime_retention.RETENTION_TTL_HOURS) - def test_missing_log_directory_is_noop(self) -> None: - shutil.rmtree(self.logs) - deleted, kept, target, missing = retain_logs(self.repo) - self.assertEqual((deleted, kept, target.resolve(), missing), (0, 0, self.logs.resolve(), True)) - - def test_keeps_symlink_and_non_regular_log_paths(self) -> None: - old_target = self.write_file(".refactor-loop/logs/target.log", "target\n", 25) - symlink_log = self.logs / "linked.log" - symlink_log.symlink_to(old_target) - fifo_log = self.logs / "pipe.log" - os.mkfifo(fifo_log) - deleted, kept, _target, _missing = retain_logs(self.repo) - self.assertEqual(deleted, 1) - self.assertEqual(kept, 2) - self.assertTrue(symlink_log.is_symlink()) - self.assertTrue(fifo_log.exists()) - self.assertFalse(old_target.exists()) - - -class LogRetentionSourceRegressionTests(unittest.TestCase): - def test_helper_contract_is_narrow_direct_delete_only(self) -> None: + def test_log_retention_wrapper_has_no_independent_owner_logic(self) -> None: text = (SCRIPT_DIR / "codex_refactor_loop" / "retention.py").read_text(encoding="utf-8") - self.assertIn("RETENTION_TTL_HOURS = 24", text) - self.assertIn(".refactor-loop", text) - self.assertIn("logs", text) - self.assertIn("path.unlink", text) - for token in ("archive", "last_processed", "while True", "nohup", "gh ", "git ", "commit", "push", "merge", "label"): - with self.subTest(token=token): - self.assertNotIn(token, text) - - def test_restart_helper_hooks_retention_before_daemon_start(self) -> None: - restart = (SCRIPT_DIR / "codex_refactor_loop" / "restart.py").read_text(encoding="utf-8") - self.assertIn("_run_log_retention", restart) - self.assertLess(restart.index("self._run_log_retention()"), restart.index("for name, command in DAEMON_COMMANDS")) - self.assertIn("log_retention warning: helper failed; continuing daemon restart", restart) - self.assertEqual(RETENTION_TTL_HOURS, 24) + self.assertIn("Compatibility alias for RuntimeRetention", text) + self.assertIn("runtime_retention_main", text) + for forbidden in ("path.unlink", "time.time", "subprocess.run", "worktree", "gh ", "git ", "commit", "push", "merge", "label"): + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden, text) if __name__ == "__main__": diff --git a/skills/codex-refactor-loop/scripts/test_restart_daemons.py b/skills/codex-refactor-loop/scripts/test_restart_daemons.py index a793d197..3aa68b70 100644 --- a/skills/codex-refactor-loop/scripts/test_restart_daemons.py +++ b/skills/codex-refactor-loop/scripts/test_restart_daemons.py @@ -23,6 +23,7 @@ from codex_refactor_loop.daemon_status import DaemonStatusProjection, collect as collect_daemon_status from codex_refactor_loop import restart from codex_refactor_loop.restart import DAEMON_COMMANDS, DaemonProcess, DaemonProcessInventory, RestartConfig, RestartDaemons, daemon_targets, restart_managed_daemon_names +from codex_refactor_loop.runtime_retention import RuntimeRetentionResult DAEMON_NAMES = restart_managed_daemon_names() @@ -81,7 +82,7 @@ def run_helper(self) -> subprocess.CompletedProcess[str]: command = (sys.executable, "-c", FAKE_DAEMON) with mock.patch("codex_refactor_loop.restart.DAEMON_COMMANDS", tuple((name, command) for name in DAEMON_NAMES)): with mock.patch("codex_refactor_loop.restart.DaemonProcessInventory.collect", return_value=DaemonProcessInventory(())): - with mock.patch("codex_refactor_loop.restart.retain_logs", return_value=(0, 0, self.repo / ".refactor-loop" / "logs", False)): + with mock.patch("codex_refactor_loop.restart.retain_runtime", return_value=self.noop_retention()): helper = RestartDaemons(self.ctx, self.config) self.helpers.append(helper) helper.run() @@ -91,7 +92,7 @@ def run_helper_with_inventory(self, inventory: DaemonProcessInventory) -> subpro command = (sys.executable, "-c", FAKE_DAEMON) with mock.patch("codex_refactor_loop.restart.DAEMON_COMMANDS", tuple((name, command) for name in DAEMON_NAMES)): with mock.patch("codex_refactor_loop.restart.DaemonProcessInventory.collect", return_value=inventory): - with mock.patch("codex_refactor_loop.restart.retain_logs", return_value=(0, 0, self.repo / ".refactor-loop" / "logs", False)): + with mock.patch("codex_refactor_loop.restart.retain_runtime", return_value=self.noop_retention()): helper = RestartDaemons(self.ctx, self.config) self.helpers.append(helper) helper.run() @@ -103,6 +104,9 @@ def collect_status_with_fake_allowlist(self, inventory: DaemonProcessInventory | with mock.patch("codex_refactor_loop.daemon_status.DaemonProcessInventory.collect", return_value=inventory or DaemonProcessInventory(())): return collect_daemon_status(repo_root=self.repo, skill_root=self.skill) + def noop_retention(self) -> RuntimeRetentionResult: + return RuntimeRetentionResult(False, 0, 0, False, 0, False, self.repo / ".refactor-loop", False) + def start_count(self, name: str) -> int: path = self.repo / ".refactor-loop" / "logs" / f"{name}.starts" return len(path.read_text(encoding="utf-8").splitlines()) if path.exists() else 0 @@ -404,7 +408,7 @@ def fake_start(helper: RestartDaemons, name: str, command: tuple[str, ...]) -> N calls.append(name) with mock.patch("codex_refactor_loop.restart.require_active_controller", return_value=decision): - with mock.patch("codex_refactor_loop.restart.retain_logs", return_value=(0, 0, self.repo / ".refactor-loop" / "logs", False)): + with mock.patch("codex_refactor_loop.restart.retain_runtime", return_value=self.noop_retention()): with mock.patch.object(RestartDaemons, "start_daemon", fake_start): with mock.patch("codex_refactor_loop.restart.maybe_run_update_check", return_value={"status": "disabled", "reason": "noop"}) as update: helper = RestartDaemons(self.ctx, self.config) @@ -414,7 +418,7 @@ def fake_start(helper: RestartDaemons, name: str, command: tuple[str, ...]) -> N update.assert_called_once_with(self.ctx, startup=True) with mock.patch("codex_refactor_loop.restart.require_active_controller", return_value=decision): - with mock.patch("codex_refactor_loop.restart.retain_logs", return_value=(0, 0, self.repo / ".refactor-loop" / "logs", False)): + with mock.patch("codex_refactor_loop.restart.retain_runtime", return_value=self.noop_retention()): with mock.patch.object(RestartDaemons, "start_daemon", fake_start): with mock.patch("codex_refactor_loop.restart.maybe_run_update_check", side_effect=RuntimeError("network")): helper = RestartDaemons(self.ctx, self.config) diff --git a/skills/codex-refactor-loop/scripts/test_runtime_exception_authorization_sources.py b/skills/codex-refactor-loop/scripts/test_runtime_exception_authorization_sources.py index ba963a3f..81a75500 100644 --- a/skills/codex-refactor-loop/scripts/test_runtime_exception_authorization_sources.py +++ b/skills/codex-refactor-loop/scripts/test_runtime_exception_authorization_sources.py @@ -35,6 +35,7 @@ "integration-sync-release-rollup-65": "## Named runtime exception — integration sync daemon(per #65)", "statusline-51": "## Claude Code statusline(per #51 consensus)", "anti-stop-restart-helper-49": "## Named runtime exception — anti-stop restart helper(per #49)", + "runtime-retention-437": "## Named runtime exception - RuntimeRetention(per #437)", "phase9-router-open-state-gate-229": "### Consensus-rnd Phase design-consensus router daemon command body", "controller-release-publisher-334": "## Named runtime exception — release-publication(per #322)", "gh-usage-accounting-455": "## Named runtime exception — gh usage accounting(per #455)", @@ -219,6 +220,54 @@ def test_issue_403_decomposition_allowlist_excludes_wakeup_plan_public_projectio self.assertNotIn(forbidden, wakeup_source) self.assertIn("wakeup_plan.py` is not the #403 owner", skill_section) + def test_runtime_retention_437_preserves_narrow_local_gc_boundary(self) -> None: + entry = mirror_entry(self.mirror, "runtime-retention-437") + skill_section = self.skill[self.skill.index("## Named runtime exception - RuntimeRetention(per #437)") :] + claude = self.repo_rules + cli_source = read(SKILL_ROOT / "scripts" / "codex_refactor_loop" / "cli.py") + runtime_source = read(SKILL_ROOT / "scripts" / "codex_refactor_loop" / "runtime_retention.py") + retention_source = read(SKILL_ROOT / "scripts" / "codex_refactor_loop" / "retention.py") + + for needle in ( + "#437", + "RuntimeRetention", + "RUNTIME_RETENTION_ENABLE=true", + "only canonical owner", + "one-release compatibility alias", + "$REPO_ROOT/.refactor-loop/{logs,prompts,runs}", + "same-inode compact", + ".refactor-loop/state/runtime-retention-plan.json", + "no_in_flight", + "no_open_issue_or_pr", + "no_dirty", + "no_local_ahead", + "merged_or_missing_safe", + "git worktree remove ", + "git worktree prune", + "no GitHub write", + "no `git fetch`", + "no branch deletion", + "no worktree cleanup without planner proof", + "no generic lifecycle actor", + ): + with self.subTest(needle=needle): + self.assertIn(needle, entry) + for needle in ( + "#437 是唯一 skill-private runtime-retention local-GC carveout", + "checked-in `RuntimeRetention` helper", + "same-inode compact", + "`git worktree remove ` 与 `git worktree prune`", + "禁止 `git fetch`", + ): + with self.subTest(needle=needle): + self.assertIn(needle, claude) + self.assertIn("Authorization source: `skills/codex-refactor-loop/authorizations/runtime-exceptions.md#runtime-retention-437`", skill_section) + self.assertIn('"runtime-retention": CommandSpec(', cli_source) + self.assertIn('"log-retention": CommandSpec(', cli_source) + self.assertIn("runtime_retention_main", cli_source) + self.assertIn("RuntimeRetentionPlan", runtime_source) + self.assertIn("Compatibility alias for RuntimeRetention", retention_source) + def test_maintainer_directive_entries_have_required_fields(self) -> None: self.assertEqual(len(MAINTAINER_DIRECTIVE_ANCHORS), 7) for anchor in MAINTAINER_DIRECTIVE_ANCHORS: diff --git a/skills/codex-refactor-loop/scripts/test_runtime_retention.py b/skills/codex-refactor-loop/scripts/test_runtime_retention.py new file mode 100644 index 00000000..3f8cf149 --- /dev/null +++ b/skills/codex-refactor-loop/scripts/test_runtime_retention.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Behavior and source-regression tests for RuntimeRetention.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +CLI = SCRIPT_DIR / "consensus-rnd-cli" + +sys.path.insert(0, str(SCRIPT_DIR)) + +from codex_refactor_loop.runtime_retention import RETENTION_TTL_HOURS, retain_runtime + + +class RuntimeRetentionBehaviorTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp_root = Path(tempfile.mkdtemp(prefix="runtime-retention-test-")) + self.repo = self.tmp_root / "repo" + self.refactor_loop = self.repo / ".refactor-loop" + for rel in ("logs", "prompts", "runs", "state"): + (self.refactor_loop / rel).mkdir(parents=True) + (self.repo / ".worktrees").mkdir(parents=True) + (self.refactor_loop / "host.env").write_text( + f'export REPO_ROOT="{self.repo}"\nexport RUNTIME_RETENTION_ENABLE="true"\n', + encoding="utf-8", + ) + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_root, ignore_errors=True) + + def write_file(self, rel: str, text: str, age_hours: float) -> Path: + path = self.repo / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + ts = time.time() - age_hours * 60 * 60 + os.utime(path, (ts, ts)) + return path + + def run_cli(self, command: str = "runtime-retention") -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(CLI), command], + cwd=self.repo, + env={"PATH": os.environ.get("PATH", ""), "PYTHONPATH": os.environ.get("PYTHONPATH", "")}, + capture_output=True, + text=True, + check=False, + ) + + def test_default_disabled_noops_even_when_old_files_exist(self) -> None: + (self.refactor_loop / "host.env").write_text( + f'export REPO_ROOT="{self.repo}"\nexport RUNTIME_RETENTION_ENABLE="false"\n', + encoding="utf-8", + ) + old_log = self.write_file(".refactor-loop/logs/old.log", "done\nEXIT=0\n", 25) + + result = self.run_cli() + + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("runtime_retention: enabled=false", result.stdout) + self.assertTrue(old_log.exists()) + + def test_deletes_only_host_opt_in_generated_regular_files_older_than_24h(self) -> None: + old_log = self.write_file(".refactor-loop/logs/old.log", "done\nEXIT=0\n", 25) + old_prompt = self.write_file(".refactor-loop/prompts/old.md", "prompt\n", 25) + old_run = self.write_file(".refactor-loop/runs/old.json", "{}\n", 25) + young_log = self.write_file(".refactor-loop/logs/young.log", "done\n", 1) + old_non_generated = self.write_file(".refactor-loop/logs/old.bin", "keep\n", 25) + state_artifact = self.write_file(".refactor-loop/state/old.json", "keep\n", 25) + + result = retain_runtime(self.repo, enabled=True) + + self.assertEqual((result.deleted, result.missing), (3, False)) + self.assertEqual(result.target.resolve(), self.refactor_loop.resolve()) + self.assertFalse(old_log.exists()) + self.assertFalse(old_prompt.exists()) + self.assertFalse(old_run.exists()) + self.assertTrue(young_log.exists()) + self.assertTrue(old_non_generated.exists()) + self.assertTrue(state_artifact.exists()) + self.assertEqual(RETENTION_TTL_HOURS, 24) + + def test_keeps_symlink_and_non_regular_generated_paths(self) -> None: + old_target = self.write_file(".refactor-loop/logs/target.log", "target\n", 25) + symlink_log = self.refactor_loop / "logs" / "linked.log" + symlink_log.symlink_to(old_target) + fifo_log = self.refactor_loop / "logs" / "pipe.log" + os.mkfifo(fifo_log) + + result = retain_runtime(self.repo, enabled=True) + + self.assertEqual(result.deleted, 1) + self.assertGreaterEqual(result.kept, 2) + self.assertTrue(symlink_log.is_symlink()) + self.assertTrue(fifo_log.exists()) + self.assertFalse(old_target.exists()) + + def test_pending_events_compaction_preserves_same_inode_tail(self) -> None: + pending = self.refactor_loop / ".controller-pending-events.log" + pending.write_text("".join(f"event-{index}\n" for index in range(2500)), encoding="utf-8") + inode_before = pending.stat().st_ino + + result = retain_runtime(self.repo, enabled=True) + + self.assertTrue(result.compacted_events) + self.assertEqual(inode_before, pending.stat().st_ino) + lines = pending.read_text(encoding="utf-8").splitlines() + self.assertEqual(2000, len(lines)) + self.assertEqual("event-500", lines[0]) + self.assertEqual("event-2499", lines[-1]) + + def test_stale_worktree_requires_planner_proof_and_git_recheck(self) -> None: + stale = self.repo / ".worktrees" / "iter1-issue-1" + stale.mkdir(parents=True) + plan = { + "kind": "RuntimeRetentionPlan", + "stale_worktrees": [ + { + "path": ".worktrees/iter1-issue-1", + "eligible": True, + "proof": { + "no_in_flight": True, + "no_open_issue_or_pr": True, + "no_dirty": True, + "no_local_ahead": True, + "merged_or_missing_safe": True, + }, + }, + { + "path": ".worktrees/iter2-issue-2", + "eligible": True, + "proof": { + "no_in_flight": False, + "no_open_issue_or_pr": True, + "no_dirty": True, + "no_local_ahead": True, + "merged_or_missing_safe": True, + }, + }, + ], + } + (self.refactor_loop / "state" / "runtime-retention-plan.json").write_text(json.dumps(plan), encoding="utf-8") + commands: list[tuple[str, ...]] = [] + + def runner(command): + commands.append(tuple(command)) + if command[3:5] == ("status", "--porcelain"): + return subprocess.CompletedProcess(command, 0, "", "") + if command[3:6] == ("rev-list", "--count", "@{upstream}..HEAD"): + return subprocess.CompletedProcess(command, 0, "0\n", "") + return subprocess.CompletedProcess(command, 0, "", "") + + result = retain_runtime(self.repo, enabled=True, command_runner=runner) + + self.assertEqual(1, result.removed_worktrees) + self.assertTrue(result.pruned_worktrees) + self.assertIn(("git", "-C", str(self.repo.resolve()), "worktree", "remove", str(stale.resolve())), commands) + self.assertIn(("git", "-C", str(self.repo.resolve()), "worktree", "prune"), commands) + self.assertFalse(any(".worktrees/iter2-issue-2" in " ".join(command) for command in commands)) + + def test_cli_uses_host_env_and_reports_summary(self) -> None: + self.write_file(".refactor-loop/logs/old.log", "done\nEXIT=0\n", 25) + result = self.run_cli() + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("runtime_retention: enabled=true ttl_hours=24 deleted=1", result.stdout) + self.assertIn("removed_worktrees=0", result.stdout) + + def test_refuses_without_repo_root_or_host_env(self) -> None: + isolated = self.tmp_root / "isolated" + isolated.mkdir() + result = subprocess.run( + [sys.executable, str(CLI), "runtime-retention"], + cwd=isolated, + env={"PATH": os.environ.get("PATH", ""), "PYTHONPATH": os.environ.get("PYTHONPATH", "")}, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(2, result.returncode) + self.assertIn("REPO_ROOT is unset", result.stderr) + + +class RuntimeRetentionSourceRegressionTests(unittest.TestCase): + def test_runtime_retention_contract_is_narrow(self) -> None: + text = (SCRIPT_DIR / "codex_refactor_loop" / "runtime_retention.py").read_text(encoding="utf-8") + for token in ( + "RUNTIME_RETENTION_ENABLE", + "RuntimeRetentionPlan", + "PENDING_EVENTS_MAX_LINES", + ".controller-pending-events.log", + "same-inode", + '"worktree", "remove"', + '"worktree", "prune"', + "no_in_flight", + "no_open_issue_or_pr", + "no_dirty", + "no_local_ahead", + "merged_or_missing_safe", + ): + with self.subTest(token=token): + self.assertIn(token, text) + for forbidden in ("gh ", "gh-", '"fetch"', '"push"', '"merge"', '"reset"', '"rebase"', '"commit"', '"label"', '"release"', "archive", "index"): + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden, text) + + def test_restart_helper_runs_runtime_retention_before_daemon_start(self) -> None: + restart = (SCRIPT_DIR / "codex_refactor_loop" / "restart.py").read_text(encoding="utf-8") + self.assertIn("_run_runtime_retention", restart) + self.assertLess(restart.index("self._run_runtime_retention()"), restart.index("for name, command in DAEMON_COMMANDS")) + self.assertIn("runtime_retention warning: helper failed; continuing daemon restart", restart) + self.assertNotIn("_run_log_retention", restart) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/codex-refactor-loop/scripts/test_skill_entrypoint_contract.py b/skills/codex-refactor-loop/scripts/test_skill_entrypoint_contract.py index 9f8a06c7..f740d177 100644 --- a/skills/codex-refactor-loop/scripts/test_skill_entrypoint_contract.py +++ b/skills/codex-refactor-loop/scripts/test_skill_entrypoint_contract.py @@ -529,7 +529,17 @@ def test_issue297_controller_runbook_uses_named_projections_and_actions(self) -> "git branch -D", ) allowed_history = "must not run `gh pr create`" - skill_without_forbidden_history = self.skill.replace(allowed_history, "") + runtime_retention = section_between( + self.skill, + r"^## Named runtime exception - RuntimeRetention\(per #437\)$", + r"^## Large issue decomposition", + ) + skill_without_forbidden_history = self.skill.replace(allowed_history, "").replace(runtime_retention, "") + skill_without_forbidden_history = re.sub( + r"(?m)^.*(?:RuntimeRetention|runtime-retention).*\n?", + "", + skill_without_forbidden_history, + ) for needle in forbidden: with self.subTest(needle=needle): self.assertNotIn(needle, skill_without_forbidden_history) diff --git a/skills/codex-refactor-loop/scripts/test_skill_reference_anchors.py b/skills/codex-refactor-loop/scripts/test_skill_reference_anchors.py index b891da5f..e3590618 100644 --- a/skills/codex-refactor-loop/scripts/test_skill_reference_anchors.py +++ b/skills/codex-refactor-loop/scripts/test_skill_reference_anchors.py @@ -267,6 +267,27 @@ def test_issue_decomposition_discoverability_uses_pending_events_completed_marke with self.subTest(forbidden=forbidden): self.assertIn(forbidden, section) + def test_runtime_retention_anchor_documents_canonical_owner_and_alias(self) -> None: + section = section_after_anchor(self.skill, "named-runtime-exception--runtime-retentionper-437") + for needle in ( + "RuntimeRetention(per #437)", + "runtime-retention-437", + "`consensus-rnd-cli runtime-retention` is the canonical command", + "`consensus-rnd-cli log-retention` is a one-release compatibility alias", + "$RUNTIME_RETENTION_ENABLE=true", + "$REPO_ROOT/.refactor-loop/{logs,prompts,runs}", + "same inode", + ".controller-pending-events.log", + ".refactor-loop/state/runtime-retention-plan.json", + "git worktree remove ", + "git worktree prune", + "no `git fetch`", + "no GitHub write or lifecycle authority", + "test_runtime_retention.py", + ): + with self.subTest(needle=needle): + self.assertIn(needle, section) + # Refactor (iter364/issue364): # Old pattern: Path-A solvers dispatched with --cd $REPO_ROOT (integration checkout) can't see work-unit source when the issue references files on a divergent non-integration branch, emitting spurious no-plan and wasting rounds. # New principle: Contract-only source locator: SKILL solver source contract + 3 solver prompts document a read-only source-locator recipe (git show : / raw URL / gh api / host.env), classify missing/invalid locator as source-location-missing-or-invalid; NO new projection/parser/header/module.