diff --git a/nightshift/pipeline.py b/nightshift/pipeline.py index 3f2bbeb..fbd63b7 100644 --- a/nightshift/pipeline.py +++ b/nightshift/pipeline.py @@ -174,6 +174,23 @@ class PipelineRunner: retry_notes.append(f"Context update from '{stage.id}': {result.context_update}") if result.status == "pass": + if result.next_stage: + if result.next_stage not in stage_indexes: + final_status = "failed" + final_reason = ( + f"Stage '{stage.id}' requested unknown next stage '{result.next_stage}'." + ) + break + self.logger.event( + "stage.next", + "Jumping to requested next stage", + run_id=self.artifacts.run_id, + task_id=task.id, + stage_id=stage.id, + next_stage=result.next_stage, + ) + index = stage_indexes[result.next_stage] + continue index += 1 continue @@ -595,11 +612,24 @@ class PipelineRunner: except PipelineError: summary_filename = "implementation-summary.md" if retry_count == 0 else f"repair-summary-{retry_count}.md" reason = str(exc) - if "generated patch has no changes" in reason and retry_count: - reason = ( - "File writer error: repair output produced no changes relative to " - "the current workspace. The previous patch was applied, tests failed, " - "and the repair attempt repeated the already-applied file content." + if "generated patch has no changes" in reason: + next_stage = self._stage_after_patch_flow(stage.id) + reason = self._no_changes_reason(retry_count) + summary_path = self.artifacts.write_stage_output( + task.id, + summary_filename, + f"# Implementation Summary\n\nStatus: pass\nReason: {reason}\n", + ) + return StageResult( + stage.id, + "pass", + reason, + output_path=result.output_path, + next_stage=next_stage, + context_update=( + f"Implementation summary: " + f"{summary_path.relative_to(self.config.project.root).as_posix()}" + ), ) self.artifacts.write_stage_output( task.id, @@ -641,6 +671,32 @@ class PipelineRunner: suffix = f"-{retry_count}" if retry_count else "" return replace(stage, output=f"{stage.id}-agent-output{suffix}.md") + def _stage_after_patch_flow(self, current_stage_id: str) -> str | None: + stages = list(self.config.pipeline.stages) + stage_indexes = {stage.id: index for index, stage in enumerate(stages)} + start = stage_indexes.get(current_stage_id) + if start is None: + return None + patch_stage_types = {"patch_normalizer", "patch_validator", "patch_apply"} + for stage in stages[start + 1:]: + if stage.type in patch_stage_types: + continue + return stage.id + return None + + def _no_changes_reason(self, retry_count: int) -> str: + if retry_count: + return ( + "File writer produced no changes relative to the current workspace. " + "The previous patch may already be applied; skipping patch stages and " + "continuing with verification." + ) + return ( + "File writer produced no changes relative to the current workspace. " + "The task may already be applied locally; skipping patch stages and " + "continuing with verification." + ) + def _run_patch_normalizer_stage( self, stage: StageConfig, diff --git a/nightshift/web.py b/nightshift/web.py index b5bb4f6..594e8b3 100644 --- a/nightshift/web.py +++ b/nightshift/web.py @@ -57,6 +57,7 @@ def read_artifact(run_path: Path, relative_path: str) -> str: def render_dashboard(artifact_dir: str | Path) -> str: + artifact_path = Path(artifact_dir).resolve() runs = list_runs(artifact_dir) body = [ '', @@ -64,7 +65,7 @@ def render_dashboard(artifact_dir: str | Path) -> str: '
', '
', '
NightShift logo

Local artifact dashboard

NightShift

', - '
Read-only run review. Auto-refreshes every 5 seconds.
', + f'
Read-only run review. Auto-refreshes every 5 seconds.
{escape(str(artifact_path))}
', "
", ] if not runs: @@ -244,6 +245,7 @@ h1 { font-size: 40px; line-height: 1; } h2 { font-size: 18px; } h3 { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .10em; margin-bottom: 12px; } .hero-copy { color: var(--muted); max-width: 420px; text-align: right; } +.path-pill { display: inline-block; margin-top: 8px; color: #c9d7ea; font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .run-card, .empty { border: 1px solid var(--line); background: rgba(16, 22, 34, .84); diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 3a8ac02..3864e50 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -498,6 +498,55 @@ Acceptance Criteria: self.assertEqual(result.status, "complete") self.assertIn("@@ -1 +1 @@", patch.read_text(encoding="utf-8")) + def test_file_writer_no_changes_skips_patch_stages_and_runs_tests(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_common_files(root) + (root / "app.py").write_text("new\n", encoding="utf-8") + (root / "fake_writer.py").write_text( + "\n".join( + [ + "print('```file:app.py')", + "print('new')", + "print('```')", + ] + ), + encoding="utf-8", + ) + test_command = 'python -c "from pathlib import Path; raise SystemExit(0 if Path(\'app.py\').read_text() == \'new\\n\' else 1)"' + stages = ( + StageConfig(id="write", type="file_writer", agent="writer"), + StageConfig(id="normalize", type="patch_normalizer"), + StageConfig(id="validate", type="patch_validator"), + StageConfig(id="apply", type="patch_apply", mode="apply"), + StageConfig(id="test", type="command", commands=(test_command,), output="test-output.txt"), + ) + config = make_config(root, stages) + config = replace( + config, + safety=SafetyConfig( + require_clean_worktree=False, + scoped_paths=(".",), + allowed_commands=(test_command,), + forbidden_commands=("rm -rf",), + ), + ) + config.agents["writer"] = AgentConfig( + id="writer", + backend="command", + command="python fake_writer.py", + system_prompt=Path("planner.md"), + ) + runner = PipelineRunner(config, ArtifactStore(root, ".nightshift", run_id="test-run")) + + result = runner.run_task(parse_tasks(TASK_MD)[0]) + + task_dir = root / ".nightshift" / "runs" / "test-run" / "tasks" / "TASK-001" + self.assertEqual(result.status, "complete") + self.assertTrue((task_dir / "test-output.txt").exists()) + self.assertFalse((task_dir / "normalized.patch").exists()) + self.assertFalse((task_dir / "patch-validation.md").exists()) + def test_patch_validator_rejects_unsafe_patch(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/test_web.py b/tests/test_web.py index 0ff0730..ba15702 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -39,6 +39,7 @@ class WebDashboardTests(unittest.TestCase): self.assertIn("Log Tail", dashboard) self.assertIn("Planner proposed", dashboard) self.assertIn("FAILED", dashboard) + self.assertIn(str((root / ".nightshift").resolve()), dashboard) self.assertIn("/assets/logo.png", dashboard) self.assertIn("artifact-link", dashboard) self.assertIn("line 119", dashboard)