specfuse-loop 0.3.15__tar.gz → 0.3.16__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {specfuse_loop-0.3.15/specfuse_loop.egg-info → specfuse_loop-0.3.16}/PKG-INFO +1 -1
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/pyproject.toml +1 -1
- specfuse_loop-0.3.16/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/loop.py +80 -12
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16/specfuse_loop.egg-info}/PKG-INFO +1 -1
- specfuse_loop-0.3.16/tests/test_loop_reset_preserving_events.py +294 -0
- specfuse_loop-0.3.15/specfuse/loop/data/VERSION +0 -1
- specfuse_loop-0.3.15/tests/test_loop_reset_preserving_events.py +0 -142
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/LICENSE +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/NOTICE +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/README.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/setup.cfg +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/_filelock.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/_miniyaml.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/docs/getting-started.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/docs/methodology.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/docs/skills.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/roadmap.template.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/rules/verification-discipline.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/schemas/event.schema.json +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/templates/PLAN.template.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/verification.yml.example +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/gh_backend.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/lint_plan.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/scaffold.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse_loop.egg-info/SOURCES.txt +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_attempt_outcome_emission.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_autoclose_deferral_visibility.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_backend.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_bash_routing.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_bookkeeping_no_verify.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_claude_resolution.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_close_wu_self_reference.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_console_encoding.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_cost_tracking.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_degraded_oracle.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_driver_integration.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_duration_tracking.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_ensure_feature_branch_base.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_events_redaction.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_filelock_portable.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_gh_backend.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_interpreter_normalization.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_learnings_query.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_plan_errors.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_plan_relative_import.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_files_changed_guard.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_orchestration.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_post_pass_invariant.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_zero_token_guard.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_miniyaml_negative.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_prepare_scaffold_sync.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_redaction_windows_home.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_resolve_base.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_roadmap_archive_skill.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_doc_hygiene.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_upgrade.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_skills_vendored_in_sync.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_timeout_kill_portable.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_untracked_file_absorption.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_upgrade_merge_gate.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_version_skew.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_windows_gate_exec.py +0 -0
- {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_wu_execution_metadata.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "specfuse-loop"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.16"
|
|
8
8
|
description = "Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.16
|
|
@@ -63,7 +63,7 @@ SPECFUSE_DIR = Path(".specfuse")
|
|
|
63
63
|
REPO_ROOT = SPECFUSE_DIR.parent
|
|
64
64
|
FEATURES_DIR = SPECFUSE_DIR / "features"
|
|
65
65
|
VERIFICATION_PATH = SPECFUSE_DIR / "verification.yml"
|
|
66
|
-
DRIVER_VERSION = "0.3.
|
|
66
|
+
DRIVER_VERSION = "0.3.16"
|
|
67
67
|
# Oldest scaffold layout this driver can drive. init.sh stamps the scaffold's own
|
|
68
68
|
# version into `.specfuse/VERSION`; check_scaffold_version() fails loud at startup if
|
|
69
69
|
# the consumer's scaffold is older than this, pointing at `specfuse upgrade`. Bump
|
|
@@ -1410,7 +1410,49 @@ def commit_bookkeeping(paths: list, message: str) -> str | None:
|
|
|
1410
1410
|
return git("rev-parse", "HEAD")
|
|
1411
1411
|
|
|
1412
1412
|
|
|
1413
|
-
def
|
|
1413
|
+
def _clean_attempt_untracked(
|
|
1414
|
+
untracked_before: "set[str] | frozenset[str]", events_path: Path,
|
|
1415
|
+
) -> None:
|
|
1416
|
+
"""Delete untracked files that appeared since the *untracked_before* snapshot.
|
|
1417
|
+
|
|
1418
|
+
Helper for `reset_preserving_events`; see its docstring for why. Deletes
|
|
1419
|
+
FILES only — empty directories are left behind, which is harmless (git
|
|
1420
|
+
does not track directories) and avoids racing the driver's own mid-run
|
|
1421
|
+
scratch dirs.
|
|
1422
|
+
|
|
1423
|
+
Never deletes: paths in *untracked_before* (the operator's, per #150),
|
|
1424
|
+
*events_path* (driver-managed, and untracked on a fresh branch), or
|
|
1425
|
+
gitignored paths (`untracked_paths` honors .gitignore, so a feature's
|
|
1426
|
+
`work/` scratch dir never enters the delete set).
|
|
1427
|
+
|
|
1428
|
+
Best-effort: any git or filesystem error is swallowed. A failure to clean
|
|
1429
|
+
reverts to the old leftover behavior, which must not crash the run.
|
|
1430
|
+
"""
|
|
1431
|
+
try:
|
|
1432
|
+
root = Path(git("rev-parse", "--show-toplevel"))
|
|
1433
|
+
candidates = untracked_paths() - set(untracked_before)
|
|
1434
|
+
except (subprocess.CalledProcessError, OSError):
|
|
1435
|
+
return
|
|
1436
|
+
try:
|
|
1437
|
+
keep = events_path.resolve()
|
|
1438
|
+
except OSError:
|
|
1439
|
+
keep = None
|
|
1440
|
+
for rel in sorted(candidates):
|
|
1441
|
+
target = root / rel
|
|
1442
|
+
try:
|
|
1443
|
+
if keep is not None and target.resolve() == keep:
|
|
1444
|
+
continue
|
|
1445
|
+
if target.is_file() or target.is_symlink():
|
|
1446
|
+
target.unlink()
|
|
1447
|
+
except OSError:
|
|
1448
|
+
continue
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def reset_preserving_events(
|
|
1452
|
+
head_before: str,
|
|
1453
|
+
events_path: Path,
|
|
1454
|
+
untracked_before: "set[str] | frozenset[str] | None" = None,
|
|
1455
|
+
) -> None:
|
|
1414
1456
|
"""`git reset --hard <head_before>` without losing events.jsonl content.
|
|
1415
1457
|
|
|
1416
1458
|
The hard-reset is the methodology's "wipe agent's edits before we write our
|
|
@@ -1433,11 +1475,27 @@ def reset_preserving_events(head_before: str, events_path: Path) -> None:
|
|
|
1433
1475
|
|
|
1434
1476
|
Subsequent `flush_events` calls then append to the preserved content;
|
|
1435
1477
|
`commit_bookkeeping` captures the full history.
|
|
1478
|
+
|
|
1479
|
+
`untracked_before` (#162) is the per-WU untracked snapshot taken at
|
|
1480
|
+
dispatch. When supplied, untracked files that appeared SINCE it are
|
|
1481
|
+
deleted after the reset. A hard reset rolls back tracked content but
|
|
1482
|
+
leaves untracked files on disk, and the snapshot is taken once per WU
|
|
1483
|
+
(outside the attempt loop) — so without this, attempt 1's new files
|
|
1484
|
+
survive into attempt 2, where they are indistinguishable from attempt 2's
|
|
1485
|
+
own work: `verify_files_changed` passes them and `squash_commit` commits
|
|
1486
|
+
them, letting an attempt pass on its predecessor's leftovers.
|
|
1487
|
+
|
|
1488
|
+
Absence is inert BY DESIGN. An empty set would read as "the operator had
|
|
1489
|
+
nothing untracked" and delete the entire untracked working tree; `None`
|
|
1490
|
+
means "no snapshot available, do not clean" and preserves the pre-#162
|
|
1491
|
+
behavior for callers that cannot supply one.
|
|
1436
1492
|
"""
|
|
1437
1493
|
saved = events_path.read_text() if events_path.is_file() else None
|
|
1438
1494
|
git("reset", "--hard", head_before)
|
|
1439
1495
|
if saved is not None:
|
|
1440
1496
|
events_path.write_text(saved)
|
|
1497
|
+
if untracked_before is not None:
|
|
1498
|
+
_clean_attempt_untracked(untracked_before, events_path)
|
|
1441
1499
|
|
|
1442
1500
|
|
|
1443
1501
|
class SquashCommitError(RuntimeError):
|
|
@@ -3801,7 +3859,8 @@ def run(
|
|
|
3801
3859
|
wu, attempt, "zero_token_skip",
|
|
3802
3860
|
attempts_usage[-1],
|
|
3803
3861
|
))
|
|
3804
|
-
reset_preserving_events(head_before, events_path
|
|
3862
|
+
reset_preserving_events(head_before, events_path,
|
|
3863
|
+
untracked_before=untracked_before)
|
|
3805
3864
|
print(f" ZERO-TOKEN attempt {attempt}/{MAX_ATTEMPTS} "
|
|
3806
3865
|
f"— no agent output, skipping")
|
|
3807
3866
|
continue
|
|
@@ -3812,7 +3871,8 @@ def run(
|
|
|
3812
3871
|
# reset wipe the flip — the silent-state-loss bug.
|
|
3813
3872
|
# Use reset_preserving_events to keep prior WU's
|
|
3814
3873
|
# flushed-but-uncommitted events.jsonl entries.
|
|
3815
|
-
reset_preserving_events(head_before, events_path
|
|
3874
|
+
reset_preserving_events(head_before, events_path,
|
|
3875
|
+
untracked_before=untracked_before)
|
|
3816
3876
|
backend.set_wu(wu, "status", "blocked_human")
|
|
3817
3877
|
write_cost_to_wu(backend, wu, cum_usage)
|
|
3818
3878
|
wu_events.append(emit_attempt_outcome(
|
|
@@ -3856,7 +3916,8 @@ def run(
|
|
|
3856
3916
|
# written just above — record the failure with git's
|
|
3857
3917
|
# stderr, and retry within budget (MAX_ATTEMPTS
|
|
3858
3918
|
# exhaustion escalates to blocked_human).
|
|
3859
|
-
reset_preserving_events(head_before, events_path
|
|
3919
|
+
reset_preserving_events(head_before, events_path,
|
|
3920
|
+
untracked_before=untracked_before)
|
|
3860
3921
|
# Redact any quoted leak-scan match before it lands
|
|
3861
3922
|
# in events.jsonl / the attempt note, so the captured
|
|
3862
3923
|
# FINDINGS text can't re-trip the next bookkeeping
|
|
@@ -3887,7 +3948,8 @@ def run(
|
|
|
3887
3948
|
smoke_cmds, Path("."),
|
|
3888
3949
|
)
|
|
3889
3950
|
if not smoke_ok:
|
|
3890
|
-
reset_preserving_events(head_before, events_path
|
|
3951
|
+
reset_preserving_events(head_before, events_path,
|
|
3952
|
+
untracked_before=untracked_before)
|
|
3891
3953
|
wu_events.append(emit_attempt_outcome(
|
|
3892
3954
|
wu, attempt, "smoke_import_failed",
|
|
3893
3955
|
attempts_usage[-1],
|
|
@@ -3904,7 +3966,8 @@ def run(
|
|
|
3904
3966
|
wu, feature_dir, REPO_ROOT, head_before,
|
|
3905
3967
|
)
|
|
3906
3968
|
if not closing_ok:
|
|
3907
|
-
reset_preserving_events(head_before, events_path
|
|
3969
|
+
reset_preserving_events(head_before, events_path,
|
|
3970
|
+
untracked_before=untracked_before)
|
|
3908
3971
|
# `assertion` names the primary (first) failing
|
|
3909
3972
|
# assertion. With aggregation (#72) closing_summary
|
|
3910
3973
|
# may list several; the first `assert_*` token is the
|
|
@@ -3940,7 +4003,8 @@ def run(
|
|
|
3940
4003
|
# every current WU is unchanged.
|
|
3941
4004
|
deliv_ok, deliv_summary = assert_declared_deliverables(wu)
|
|
3942
4005
|
if not deliv_ok:
|
|
3943
|
-
reset_preserving_events(head_before, events_path
|
|
4006
|
+
reset_preserving_events(head_before, events_path,
|
|
4007
|
+
untracked_before=untracked_before)
|
|
3944
4008
|
missing = deliv_summary.split(": ", 1)[-1]
|
|
3945
4009
|
wu_events.append(emit_attempt_outcome(
|
|
3946
4010
|
wu, attempt, "deliverable_missing",
|
|
@@ -3966,7 +4030,8 @@ def run(
|
|
|
3966
4030
|
wu, touched,
|
|
3967
4031
|
)
|
|
3968
4032
|
if not impl_ok:
|
|
3969
|
-
reset_preserving_events(head_before, events_path
|
|
4033
|
+
reset_preserving_events(head_before, events_path,
|
|
4034
|
+
untracked_before=untracked_before)
|
|
3970
4035
|
wu_events.append(emit_attempt_outcome(
|
|
3971
4036
|
wu, attempt, "no_deliverable_files",
|
|
3972
4037
|
attempts_usage[-1],
|
|
@@ -4068,7 +4133,8 @@ def run(
|
|
|
4068
4133
|
))
|
|
4069
4134
|
attempt_notes.append((attempt, note))
|
|
4070
4135
|
failure_note = note
|
|
4071
|
-
reset_preserving_events(head_before, events_path
|
|
4136
|
+
reset_preserving_events(head_before, events_path,
|
|
4137
|
+
untracked_before=untracked_before)
|
|
4072
4138
|
print(f" FILES_CHANGED MISMATCH attempt "
|
|
4073
4139
|
f"{attempt}/{MAX_ATTEMPTS} — {len(payload)} path(s) "
|
|
4074
4140
|
f"unchanged")
|
|
@@ -4101,7 +4167,8 @@ def run(
|
|
|
4101
4167
|
"attempts": attempt,
|
|
4102
4168
|
"attempts_usage": attempts_usage,
|
|
4103
4169
|
}))
|
|
4104
|
-
reset_preserving_events(head_before, events_path
|
|
4170
|
+
reset_preserving_events(head_before, events_path,
|
|
4171
|
+
untracked_before=untracked_before)
|
|
4105
4172
|
backend.set_wu(wu, "status", "blocked_human")
|
|
4106
4173
|
write_cost_to_wu(backend, wu, cum_usage)
|
|
4107
4174
|
flush_events(events_path, wu_events)
|
|
@@ -4119,7 +4186,8 @@ def run(
|
|
|
4119
4186
|
prior_failure_signature = (_fc, _fs)
|
|
4120
4187
|
flush_events(events_path, wu_events)
|
|
4121
4188
|
wu_events.clear()
|
|
4122
|
-
reset_preserving_events(head_before, events_path
|
|
4189
|
+
reset_preserving_events(head_before, events_path,
|
|
4190
|
+
untracked_before=untracked_before)
|
|
4123
4191
|
print(f" FAIL attempt {attempt}/{MAX_ATTEMPTS}")
|
|
4124
4192
|
else:
|
|
4125
4193
|
# for-else: ran out of attempts without break = spinning.
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""Unit tests for `reset_preserving_events` — FEAT-2026-0015 bug fix.
|
|
6
|
+
|
|
7
|
+
Bug context: `git reset --hard head_before` between WUs rolled events.jsonl
|
|
8
|
+
back to its last-committed state, silently dropping the prior WU's lifecycle
|
|
9
|
+
events that had been flushed-but-not-yet-committed. Surfaced
|
|
10
|
+
FEAT-2026-0015/T02: ran clean, events flushed post-squash, then T03 blocked
|
|
11
|
+
→ bare hard-reset wiped T02's events.
|
|
12
|
+
|
|
13
|
+
Fix: helper captures events.jsonl content, performs hard-reset, restores
|
|
14
|
+
content. Subsequent flush_events appends; subsequent commit_bookkeeping
|
|
15
|
+
captures the full history.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
import tempfile
|
|
23
|
+
import unittest
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from tests._loop_loader import load_loop
|
|
28
|
+
|
|
29
|
+
loop = load_loop()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@contextmanager
|
|
33
|
+
def _git_repo_no_sign():
|
|
34
|
+
"""Minimal git repo with signing disabled (operator config-independent)."""
|
|
35
|
+
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
|
|
36
|
+
root = Path(tmp)
|
|
37
|
+
run = lambda *a: subprocess.run( # noqa: E731
|
|
38
|
+
["git", "-C", str(root), *a], check=True, capture_output=True,
|
|
39
|
+
)
|
|
40
|
+
run("init", "-q", "-b", "main")
|
|
41
|
+
run("config", "commit.gpgSign", "false")
|
|
42
|
+
run("config", "gc.auto", "0")
|
|
43
|
+
run("config", "user.email", "test@example.com")
|
|
44
|
+
run("config", "user.name", "Test")
|
|
45
|
+
(root / "README.md").write_text("# fixture\n")
|
|
46
|
+
run("add", ".")
|
|
47
|
+
run("commit", "-q", "-m", "init")
|
|
48
|
+
# Sync barrier
|
|
49
|
+
run("rev-parse", "HEAD")
|
|
50
|
+
# cwd swap so loop's git() helper (which runs without -C) operates
|
|
51
|
+
# on this tree
|
|
52
|
+
prev_cwd = os.getcwd()
|
|
53
|
+
try:
|
|
54
|
+
os.chdir(root)
|
|
55
|
+
yield root
|
|
56
|
+
finally:
|
|
57
|
+
os.chdir(prev_cwd)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestResetPreservingEventsHelper(unittest.TestCase):
|
|
61
|
+
"""Direct unit-level tests on reset_preserving_events()."""
|
|
62
|
+
|
|
63
|
+
def test_preserves_flushed_but_uncommitted_events_content(self):
|
|
64
|
+
"""events.jsonl modifications since head_before survive the reset."""
|
|
65
|
+
with _git_repo_no_sign() as root:
|
|
66
|
+
# Setup: commit a baseline events.jsonl + README
|
|
67
|
+
events_path = root / "events.jsonl"
|
|
68
|
+
events_path.write_text("# committed baseline\n")
|
|
69
|
+
subprocess.run(
|
|
70
|
+
["git", "-C", str(root), "add", "events.jsonl"],
|
|
71
|
+
check=True, capture_output=True,
|
|
72
|
+
)
|
|
73
|
+
subprocess.run(
|
|
74
|
+
["git", "-C", str(root), "commit", "-q", "-m", "baseline"],
|
|
75
|
+
check=True, capture_output=True,
|
|
76
|
+
)
|
|
77
|
+
head_before = loop.git("rev-parse", "HEAD")
|
|
78
|
+
|
|
79
|
+
# Simulate prior-WU events flushed post-squash, not yet committed:
|
|
80
|
+
# events.jsonl on disk has new content vs head_before's commit.
|
|
81
|
+
events_path.write_text(
|
|
82
|
+
"# committed baseline\n"
|
|
83
|
+
'{"correlation_id":"FEAT-PRIOR/T01","event_type":"task_completed"}\n'
|
|
84
|
+
)
|
|
85
|
+
# Simulate an agent's tracked-file edit that reset SHOULD wipe.
|
|
86
|
+
readme = root / "README.md"
|
|
87
|
+
readme.write_text("# AGENT-VANDALIZED\n")
|
|
88
|
+
|
|
89
|
+
loop.reset_preserving_events(head_before, events_path)
|
|
90
|
+
|
|
91
|
+
# Reset reverted the agent's tracked-file edit
|
|
92
|
+
self.assertEqual(readme.read_text(), "# fixture\n")
|
|
93
|
+
# But events.jsonl content survived (NOT rolled back to baseline)
|
|
94
|
+
self.assertIn("FEAT-PRIOR/T01", events_path.read_text())
|
|
95
|
+
|
|
96
|
+
def test_missing_events_file_does_not_crash(self):
|
|
97
|
+
"""If events.jsonl doesn't exist, helper still resets cleanly."""
|
|
98
|
+
with _git_repo_no_sign() as root:
|
|
99
|
+
head_before = loop.git("rev-parse", "HEAD")
|
|
100
|
+
readme = root / "README.md"
|
|
101
|
+
readme.write_text("# agent garbage\n")
|
|
102
|
+
events_path = root / "events.jsonl"
|
|
103
|
+
self.assertFalse(events_path.is_file())
|
|
104
|
+
|
|
105
|
+
loop.reset_preserving_events(head_before, events_path)
|
|
106
|
+
|
|
107
|
+
self.assertEqual(readme.read_text(), "# fixture\n")
|
|
108
|
+
# events.jsonl still absent — nothing to preserve
|
|
109
|
+
self.assertFalse(events_path.is_file())
|
|
110
|
+
|
|
111
|
+
def test_preserves_multi_line_events_content(self):
|
|
112
|
+
"""Multi-line events.jsonl content fully preserved."""
|
|
113
|
+
with _git_repo_no_sign() as root:
|
|
114
|
+
events_path = root / "events.jsonl"
|
|
115
|
+
events_path.write_text("# baseline\n")
|
|
116
|
+
subprocess.run(
|
|
117
|
+
["git", "-C", str(root), "add", "events.jsonl"],
|
|
118
|
+
check=True, capture_output=True,
|
|
119
|
+
)
|
|
120
|
+
subprocess.run(
|
|
121
|
+
["git", "-C", str(root), "commit", "-q", "-m", "baseline"],
|
|
122
|
+
check=True, capture_output=True,
|
|
123
|
+
)
|
|
124
|
+
head_before = loop.git("rev-parse", "HEAD")
|
|
125
|
+
|
|
126
|
+
events_content = (
|
|
127
|
+
"# baseline\n"
|
|
128
|
+
'{"correlation_id":"X/T01","event_type":"task_started"}\n'
|
|
129
|
+
'{"correlation_id":"X/T01","event_type":"task_completed"}\n'
|
|
130
|
+
'{"correlation_id":"X/T02","event_type":"task_started"}\n'
|
|
131
|
+
)
|
|
132
|
+
events_path.write_text(events_content)
|
|
133
|
+
(root / "README.md").write_text("# vandalized\n")
|
|
134
|
+
|
|
135
|
+
loop.reset_preserving_events(head_before, events_path)
|
|
136
|
+
|
|
137
|
+
self.assertEqual((root / "README.md").read_text(), "# fixture\n")
|
|
138
|
+
self.assertEqual(events_path.read_text(), events_content)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TestResetClearsAttemptUntracked(unittest.TestCase):
|
|
142
|
+
"""Issue #162: the reset must clear the failed attempt's untracked output.
|
|
143
|
+
|
|
144
|
+
`git reset --hard` rolls back tracked content but leaves untracked files
|
|
145
|
+
on disk. Because `untracked_before` is snapshotted once per WU (loop.py
|
|
146
|
+
:3709) and the attempt loop runs beneath it (:3769), attempt 1's new
|
|
147
|
+
files survive into attempt 2 and are indistinguishable from attempt 2's
|
|
148
|
+
own work — `verify_files_changed` passes them and `squash_commit` commits
|
|
149
|
+
them, so an attempt can pass on its predecessor's leftovers.
|
|
150
|
+
|
|
151
|
+
The snapshot is the exclusion set: untracked paths present at dispatch
|
|
152
|
+
are the operator's and must survive; anything else appeared during the
|
|
153
|
+
attempt and must go.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def _snapshot(self):
|
|
157
|
+
return loop.untracked_paths()
|
|
158
|
+
|
|
159
|
+
def test_untracked_file_created_during_attempt_is_removed(self):
|
|
160
|
+
with _git_repo_no_sign() as root:
|
|
161
|
+
head_before = loop.git("rev-parse", "HEAD")
|
|
162
|
+
before = self._snapshot()
|
|
163
|
+
|
|
164
|
+
# The attempt creates a new deliverable, then fails.
|
|
165
|
+
(root / "new.sh").write_text("#!/bin/sh\necho hi\n")
|
|
166
|
+
|
|
167
|
+
loop.reset_preserving_events(
|
|
168
|
+
head_before, root / "events.jsonl", untracked_before=before,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
self.assertFalse(
|
|
172
|
+
(root / "new.sh").exists(),
|
|
173
|
+
"attempt's untracked output must not survive into the next "
|
|
174
|
+
"attempt — it is what lets a later attempt hollow-pass",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def test_operator_untracked_file_survives(self):
|
|
178
|
+
with _git_repo_no_sign() as root:
|
|
179
|
+
# Operator WIP predates dispatch (#150's concern).
|
|
180
|
+
(root / "scratch.md").write_text("my notes\n")
|
|
181
|
+
head_before = loop.git("rev-parse", "HEAD")
|
|
182
|
+
before = self._snapshot()
|
|
183
|
+
self.assertIn("scratch.md", before)
|
|
184
|
+
|
|
185
|
+
(root / "new.sh").write_text("#!/bin/sh\n")
|
|
186
|
+
|
|
187
|
+
loop.reset_preserving_events(
|
|
188
|
+
head_before, root / "events.jsonl", untracked_before=before,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
self.assertTrue((root / "scratch.md").exists())
|
|
192
|
+
self.assertEqual((root / "scratch.md").read_text(), "my notes\n")
|
|
193
|
+
self.assertFalse((root / "new.sh").exists())
|
|
194
|
+
|
|
195
|
+
def test_untracked_file_in_subdir_is_removed(self):
|
|
196
|
+
with _git_repo_no_sign() as root:
|
|
197
|
+
head_before = loop.git("rev-parse", "HEAD")
|
|
198
|
+
before = self._snapshot()
|
|
199
|
+
|
|
200
|
+
(root / "scripts").mkdir()
|
|
201
|
+
(root / "scripts" / "check.sh").write_text("#!/bin/sh\n")
|
|
202
|
+
|
|
203
|
+
loop.reset_preserving_events(
|
|
204
|
+
head_before, root / "events.jsonl", untracked_before=before,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
self.assertFalse((root / "scripts" / "check.sh").exists())
|
|
208
|
+
|
|
209
|
+
def test_events_file_survives_even_when_untracked(self):
|
|
210
|
+
"""events.jsonl is driver-managed; the cleanup must never eat it.
|
|
211
|
+
|
|
212
|
+
It can be untracked (first run on a fresh branch) and is by
|
|
213
|
+
definition not in the pre-dispatch snapshot when the driver just
|
|
214
|
+
created it — exactly the shape the cleanup deletes.
|
|
215
|
+
"""
|
|
216
|
+
with _git_repo_no_sign() as root:
|
|
217
|
+
head_before = loop.git("rev-parse", "HEAD")
|
|
218
|
+
before = self._snapshot()
|
|
219
|
+
|
|
220
|
+
events_path = root / "events.jsonl"
|
|
221
|
+
events_path.write_text(
|
|
222
|
+
'{"correlation_id":"X/T01","event_type":"task_started"}\n'
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
loop.reset_preserving_events(
|
|
226
|
+
head_before, events_path, untracked_before=before,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
self.assertTrue(events_path.is_file())
|
|
230
|
+
self.assertIn("X/T01", events_path.read_text())
|
|
231
|
+
|
|
232
|
+
def test_gitignored_file_survives(self):
|
|
233
|
+
"""Gitignored paths (e.g. the feature's `work/` dir) are out of scope.
|
|
234
|
+
|
|
235
|
+
`untracked_paths` honors .gitignore, so ignored files never enter the
|
|
236
|
+
delete set. Asserted explicitly because deleting them would destroy
|
|
237
|
+
agent scratch space mid-run.
|
|
238
|
+
"""
|
|
239
|
+
with _git_repo_no_sign() as root:
|
|
240
|
+
(root / ".gitignore").write_text("work/\n")
|
|
241
|
+
subprocess.run(
|
|
242
|
+
["git", "-C", str(root), "add", ".gitignore"],
|
|
243
|
+
check=True, capture_output=True,
|
|
244
|
+
)
|
|
245
|
+
subprocess.run(
|
|
246
|
+
["git", "-C", str(root), "commit", "-q", "-m", "ignore"],
|
|
247
|
+
check=True, capture_output=True,
|
|
248
|
+
)
|
|
249
|
+
head_before = loop.git("rev-parse", "HEAD")
|
|
250
|
+
before = self._snapshot()
|
|
251
|
+
|
|
252
|
+
(root / "work").mkdir()
|
|
253
|
+
(root / "work" / "scratch.txt").write_text("agent scratch\n")
|
|
254
|
+
|
|
255
|
+
loop.reset_preserving_events(
|
|
256
|
+
head_before, root / "events.jsonl", untracked_before=before,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
self.assertTrue((root / "work" / "scratch.txt").exists())
|
|
260
|
+
|
|
261
|
+
def test_omitting_snapshot_preserves_untracked(self):
|
|
262
|
+
"""Back-compat: no snapshot means no cleanup.
|
|
263
|
+
|
|
264
|
+
An empty set is NOT the right default — it reads as "the operator had
|
|
265
|
+
nothing untracked", which would make every existing call site delete
|
|
266
|
+
the whole untracked working tree. Absence must stay inert.
|
|
267
|
+
"""
|
|
268
|
+
with _git_repo_no_sign() as root:
|
|
269
|
+
head_before = loop.git("rev-parse", "HEAD")
|
|
270
|
+
(root / "new.sh").write_text("#!/bin/sh\n")
|
|
271
|
+
|
|
272
|
+
loop.reset_preserving_events(head_before, root / "events.jsonl")
|
|
273
|
+
|
|
274
|
+
self.assertTrue((root / "new.sh").exists())
|
|
275
|
+
|
|
276
|
+
def test_tracked_rollback_still_happens_with_cleanup(self):
|
|
277
|
+
"""The cleanup must not displace the reset's original job."""
|
|
278
|
+
with _git_repo_no_sign() as root:
|
|
279
|
+
head_before = loop.git("rev-parse", "HEAD")
|
|
280
|
+
before = self._snapshot()
|
|
281
|
+
|
|
282
|
+
(root / "README.md").write_text("# AGENT-VANDALIZED\n")
|
|
283
|
+
(root / "new.sh").write_text("#!/bin/sh\n")
|
|
284
|
+
|
|
285
|
+
loop.reset_preserving_events(
|
|
286
|
+
head_before, root / "events.jsonl", untracked_before=before,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
self.assertEqual((root / "README.md").read_text(), "# fixture\n")
|
|
290
|
+
self.assertFalse((root / "new.sh").exists())
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
if __name__ == "__main__":
|
|
294
|
+
unittest.main()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.15
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
#
|
|
2
|
-
# Copyright 2026 Specfuse contributors
|
|
3
|
-
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
-
#
|
|
5
|
-
"""Unit tests for `reset_preserving_events` — FEAT-2026-0015 bug fix.
|
|
6
|
-
|
|
7
|
-
Bug context: `git reset --hard head_before` between WUs rolled events.jsonl
|
|
8
|
-
back to its last-committed state, silently dropping the prior WU's lifecycle
|
|
9
|
-
events that had been flushed-but-not-yet-committed. Surfaced
|
|
10
|
-
FEAT-2026-0015/T02: ran clean, events flushed post-squash, then T03 blocked
|
|
11
|
-
→ bare hard-reset wiped T02's events.
|
|
12
|
-
|
|
13
|
-
Fix: helper captures events.jsonl content, performs hard-reset, restores
|
|
14
|
-
content. Subsequent flush_events appends; subsequent commit_bookkeeping
|
|
15
|
-
captures the full history.
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
from __future__ import annotations
|
|
19
|
-
|
|
20
|
-
import os
|
|
21
|
-
import subprocess
|
|
22
|
-
import tempfile
|
|
23
|
-
import unittest
|
|
24
|
-
from contextlib import contextmanager
|
|
25
|
-
from pathlib import Path
|
|
26
|
-
|
|
27
|
-
from tests._loop_loader import load_loop
|
|
28
|
-
|
|
29
|
-
loop = load_loop()
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
@contextmanager
|
|
33
|
-
def _git_repo_no_sign():
|
|
34
|
-
"""Minimal git repo with signing disabled (operator config-independent)."""
|
|
35
|
-
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
|
|
36
|
-
root = Path(tmp)
|
|
37
|
-
run = lambda *a: subprocess.run( # noqa: E731
|
|
38
|
-
["git", "-C", str(root), *a], check=True, capture_output=True,
|
|
39
|
-
)
|
|
40
|
-
run("init", "-q", "-b", "main")
|
|
41
|
-
run("config", "commit.gpgSign", "false")
|
|
42
|
-
run("config", "gc.auto", "0")
|
|
43
|
-
run("config", "user.email", "test@example.com")
|
|
44
|
-
run("config", "user.name", "Test")
|
|
45
|
-
(root / "README.md").write_text("# fixture\n")
|
|
46
|
-
run("add", ".")
|
|
47
|
-
run("commit", "-q", "-m", "init")
|
|
48
|
-
# Sync barrier
|
|
49
|
-
run("rev-parse", "HEAD")
|
|
50
|
-
# cwd swap so loop's git() helper (which runs without -C) operates
|
|
51
|
-
# on this tree
|
|
52
|
-
prev_cwd = os.getcwd()
|
|
53
|
-
try:
|
|
54
|
-
os.chdir(root)
|
|
55
|
-
yield root
|
|
56
|
-
finally:
|
|
57
|
-
os.chdir(prev_cwd)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
class TestResetPreservingEventsHelper(unittest.TestCase):
|
|
61
|
-
"""Direct unit-level tests on reset_preserving_events()."""
|
|
62
|
-
|
|
63
|
-
def test_preserves_flushed_but_uncommitted_events_content(self):
|
|
64
|
-
"""events.jsonl modifications since head_before survive the reset."""
|
|
65
|
-
with _git_repo_no_sign() as root:
|
|
66
|
-
# Setup: commit a baseline events.jsonl + README
|
|
67
|
-
events_path = root / "events.jsonl"
|
|
68
|
-
events_path.write_text("# committed baseline\n")
|
|
69
|
-
subprocess.run(
|
|
70
|
-
["git", "-C", str(root), "add", "events.jsonl"],
|
|
71
|
-
check=True, capture_output=True,
|
|
72
|
-
)
|
|
73
|
-
subprocess.run(
|
|
74
|
-
["git", "-C", str(root), "commit", "-q", "-m", "baseline"],
|
|
75
|
-
check=True, capture_output=True,
|
|
76
|
-
)
|
|
77
|
-
head_before = loop.git("rev-parse", "HEAD")
|
|
78
|
-
|
|
79
|
-
# Simulate prior-WU events flushed post-squash, not yet committed:
|
|
80
|
-
# events.jsonl on disk has new content vs head_before's commit.
|
|
81
|
-
events_path.write_text(
|
|
82
|
-
"# committed baseline\n"
|
|
83
|
-
'{"correlation_id":"FEAT-PRIOR/T01","event_type":"task_completed"}\n'
|
|
84
|
-
)
|
|
85
|
-
# Simulate an agent's tracked-file edit that reset SHOULD wipe.
|
|
86
|
-
readme = root / "README.md"
|
|
87
|
-
readme.write_text("# AGENT-VANDALIZED\n")
|
|
88
|
-
|
|
89
|
-
loop.reset_preserving_events(head_before, events_path)
|
|
90
|
-
|
|
91
|
-
# Reset reverted the agent's tracked-file edit
|
|
92
|
-
self.assertEqual(readme.read_text(), "# fixture\n")
|
|
93
|
-
# But events.jsonl content survived (NOT rolled back to baseline)
|
|
94
|
-
self.assertIn("FEAT-PRIOR/T01", events_path.read_text())
|
|
95
|
-
|
|
96
|
-
def test_missing_events_file_does_not_crash(self):
|
|
97
|
-
"""If events.jsonl doesn't exist, helper still resets cleanly."""
|
|
98
|
-
with _git_repo_no_sign() as root:
|
|
99
|
-
head_before = loop.git("rev-parse", "HEAD")
|
|
100
|
-
readme = root / "README.md"
|
|
101
|
-
readme.write_text("# agent garbage\n")
|
|
102
|
-
events_path = root / "events.jsonl"
|
|
103
|
-
self.assertFalse(events_path.is_file())
|
|
104
|
-
|
|
105
|
-
loop.reset_preserving_events(head_before, events_path)
|
|
106
|
-
|
|
107
|
-
self.assertEqual(readme.read_text(), "# fixture\n")
|
|
108
|
-
# events.jsonl still absent — nothing to preserve
|
|
109
|
-
self.assertFalse(events_path.is_file())
|
|
110
|
-
|
|
111
|
-
def test_preserves_multi_line_events_content(self):
|
|
112
|
-
"""Multi-line events.jsonl content fully preserved."""
|
|
113
|
-
with _git_repo_no_sign() as root:
|
|
114
|
-
events_path = root / "events.jsonl"
|
|
115
|
-
events_path.write_text("# baseline\n")
|
|
116
|
-
subprocess.run(
|
|
117
|
-
["git", "-C", str(root), "add", "events.jsonl"],
|
|
118
|
-
check=True, capture_output=True,
|
|
119
|
-
)
|
|
120
|
-
subprocess.run(
|
|
121
|
-
["git", "-C", str(root), "commit", "-q", "-m", "baseline"],
|
|
122
|
-
check=True, capture_output=True,
|
|
123
|
-
)
|
|
124
|
-
head_before = loop.git("rev-parse", "HEAD")
|
|
125
|
-
|
|
126
|
-
events_content = (
|
|
127
|
-
"# baseline\n"
|
|
128
|
-
'{"correlation_id":"X/T01","event_type":"task_started"}\n'
|
|
129
|
-
'{"correlation_id":"X/T01","event_type":"task_completed"}\n'
|
|
130
|
-
'{"correlation_id":"X/T02","event_type":"task_started"}\n'
|
|
131
|
-
)
|
|
132
|
-
events_path.write_text(events_content)
|
|
133
|
-
(root / "README.md").write_text("# vandalized\n")
|
|
134
|
-
|
|
135
|
-
loop.reset_preserving_events(head_before, events_path)
|
|
136
|
-
|
|
137
|
-
self.assertEqual((root / "README.md").read_text(), "# fixture\n")
|
|
138
|
-
self.assertEqual(events_path.read_text(), events_content)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if __name__ == "__main__":
|
|
142
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|