Add status, git artifacts, task completion, multi-task runs, and dependency handling

This commit is contained in:
K. Hodges 2026-05-17 01:19:43 -07:00
parent 528c0ddeb5
commit 57608e9660
16 changed files with 765 additions and 8 deletions

View File

@ -1059,6 +1059,33 @@ Acceptance Criteria:
---
## Phase 21: Read-Only Web Dashboard
* [ ] Add a Flask-based `nightshift web` command
* [ ] Read run state from `.nightshift/runs/`
* [ ] Show latest run summary
* [ ] Show task status and retry count
* [ ] Show stage results and artifact links
* [ ] Render markdown/plain-text artifacts safely
* [ ] Add simple auto-refresh
* [ ] Keep the dashboard read-only
* [ ] Add tests for route rendering and missing artifact handling
Acceptance Criteria:
* User can monitor a run from a browser without controlling execution
* Dashboard works from existing artifact files
* Missing or partial run artifacts do not crash the server
* No config, task, command, or pipeline mutation is exposed from the UI
Notes:
* This phase should avoid websockets and process control at first.
* The dashboard should be artifact-driven so it remains decoupled from pipeline internals.
* Start/stop controls, authentication, live log streaming, and approval gates are separate future work.
---
# Appendix A: Design Decisions and Rationale
## A.1 Local-first architecture

18
docs/devlog/phase12.md Normal file
View File

@ -0,0 +1,18 @@
# Phase 12 Devlog: Status Command
## Implemented
- Added `nightshift/status.py`.
- Implemented project status inspection for config path, project root, task counts, next runnable task, latest run directory, and warnings.
- Wired `nightshift status --config ...` into the CLI.
- Added status tests.
## Decisions Made
- Status is read-only and uses existing config/task/artifact files.
- The next task is dependency-aware, so blocked tasks are not reported as runnable.
- Latest run detection is filesystem-based and uses the newest run directory by modification time.
## Notes
- Status warnings currently focus on dependency problems. Broader validation warnings can be added without changing the CLI shape.

20
docs/devlog/phase13.md Normal file
View File

@ -0,0 +1,20 @@
# Phase 13 Devlog: Git Safety and Diff Artifacts
## Implemented
- Added `nightshift/git.py`.
- Implemented clean-worktree enforcement when `require_clean_worktree` is true.
- Captured pre-run and post-run git status artifacts.
- Wrote per-task `diff.patch` artifacts.
- Handled non-git repositories and git failures gracefully when clean worktree is not required.
- Added git tests with temporary repositories.
## Decisions Made
- Clean-worktree enforcement runs before artifact creation so NightShift does not dirty a repo before checking it.
- If clean worktree is required and git status cannot be read, execution fails safely.
- Diff artifacts are written even when git is unavailable, with a readable explanation instead of crashing.
## Notes
- Existing final reports already include modified files when git status is available.

19
docs/devlog/phase14.md Normal file
View File

@ -0,0 +1,19 @@
# Phase 14 Devlog: Task Completion Updates
## Implemented
- Added task-file mutation helper to mark successful tasks complete.
- Successful runs update the target task from `[ ]` to `[x]`.
- Failed runs leave tasks incomplete.
- Added `task-completion.md` artifacts recording the completion decision.
- Added tests for task completion mutation and pipeline completion artifacts.
## Decisions Made
- Task completion uses a minimal line edit instead of rewriting the parsed task file.
- Already-completed tasks are treated as no-op updates.
- Completion happens before final report generation so reports can include task-file changes when git status is available.
## Notes
- More advanced task-file formatting preservation can be revisited if broader markdown support is added.

22
docs/devlog/phase15.md Normal file
View File

@ -0,0 +1,22 @@
# Phase 15 Devlog: Multi-Task Run Mode
## Implemented
- Added `nightshift run --all`.
- Added `PipelineRunner.run_tasks()`.
- Processes incomplete tasks in file order.
- Reuses one artifact store/run directory for the batch.
- Stops on first failure by default.
- Added `pipeline.continue_on_task_failure` config support, defaulting to false.
- Writes aggregate run summaries with completed and failed counts.
- Added multi-task tests.
## Decisions Made
- `--all` and `--task` are mutually exclusive.
- Failed and blocked tasks count as failed in aggregate summaries.
- The default remains conservative: stop on first failure unless explicitly configured otherwise.
## Notes
- Multi-task mode is still sequential. Parallel execution remains out of scope.

21
docs/devlog/phase16.md Normal file
View File

@ -0,0 +1,21 @@
# Phase 16 Devlog: Dependency Handling
## Implemented
- Parsed existing `Dependencies:` bullets into dependency lists.
- Added dependency validation for missing references and simple cycles.
- Added dependency-aware next-task selection.
- Blocked specific task runs when dependencies are incomplete.
- Blocked multi-task entries when dependencies are not satisfied by completed or earlier successful tasks.
- Reported dependency warnings through status.
- Added dependency tests.
## Decisions Made
- Dependencies are simple task IDs listed as bullets under `Dependencies:`.
- Dependency enforcement is deterministic and follows task file order.
- Missing references and cycles are validation errors; incomplete dependencies are runtime blockers.
## Notes
- No dependency solver or reordering is implemented. File order remains the source of execution order.

View File

@ -10,7 +10,14 @@ from .config import validate_config
from .errors import NightShiftError
from .init import init_project
from .pipeline import PipelineRunner
from .tasks import parse_task_file, select_next_incomplete_task, select_task_by_id
from .status import build_status, format_status
from .tasks import (
ensure_dependencies_satisfied,
parse_task_file,
select_next_runnable_task,
select_task_by_id,
validate_task_dependencies,
)
def build_parser() -> argparse.ArgumentParser:
@ -29,8 +36,10 @@ def build_parser() -> argparse.ArgumentParser:
run_parser = subparsers.add_parser("run", help="Run the configured pipeline for one task.")
run_parser.add_argument("--config", default="nightshift.yaml", help="Config file to use.")
run_parser.add_argument("--task", help="Specific task id to run.")
run_parser.add_argument("--all", action="store_true", help="Run all runnable incomplete tasks.")
subparsers.add_parser("status", help="Status reporting is planned for a later phase.")
status_parser = subparsers.add_parser("status", help="Inspect NightShift project status.")
status_parser.add_argument("--config", default="nightshift.yaml", help="Config file to inspect.")
return parser
@ -50,6 +59,7 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "validate":
config = validate_config(args.config)
tasks = parse_task_file(config.project.root, config.project.task_file)
validate_task_dependencies(tasks)
incomplete = sum(1 for task in tasks if not task.completed)
print(f"Config valid: {config.path}")
print(f"Project: {config.project.name}")
@ -61,8 +71,23 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "run":
config = validate_config(args.config)
tasks = parse_task_file(config.project.root, config.project.task_file)
task = select_task_by_id(tasks, args.task) if args.task else select_next_incomplete_task(tasks)
result = PipelineRunner(config).run_task(task)
validate_task_dependencies(tasks)
if args.all and args.task:
parser.error("run accepts either --all or --task, not both.")
runner = PipelineRunner(config)
if args.all:
selected = [task for task in tasks if not task.completed]
result = runner.run_tasks(selected)
print(f"Status: {result.status}")
print(f"Tasks run: {len(result.task_results)}")
print(f"Completed: {result.completed_count}")
print(f"Failed: {result.failed_count}")
print(f"Reason: {result.reason}")
return 0 if result.status == "complete" else 1
task = select_task_by_id(tasks, args.task) if args.task else select_next_runnable_task(tasks)
ensure_dependencies_satisfied(tasks, task)
result = runner.run_task(task)
print(f"Task: {result.task_id}")
print(f"Status: {result.status}")
print(f"Retries: {result.retry_count}")
@ -70,8 +95,11 @@ def main(argv: list[str] | None = None) -> int:
print(f"Reason: {result.reason}")
return 0 if result.status == "complete" else 1
if args.command in {"status"}:
parser.error(f"'{args.command}' is not implemented yet.")
if args.command == "status":
config = validate_config(args.config)
tasks = parse_task_file(config.project.root, config.project.task_file)
print(format_status(build_status(config, tasks)))
return 0
except NightShiftError as exc:
print(str(exc), file=sys.stderr)

View File

@ -58,6 +58,7 @@ class StageConfig:
class PipelineConfig:
max_task_retries: int
stages: tuple[StageConfig, ...]
continue_on_task_failure: bool = False
@dataclass(frozen=True)
@ -188,6 +189,10 @@ def parse_config(raw: dict[str, Any], config_path: Path) -> NightShiftConfig:
)
if max_task_retries < 0:
raise ConfigError("Config error: pipeline.max_task_retries must be zero or greater.")
continue_on_task_failure = _optional_bool(
pipeline_raw.get("continue_on_task_failure", False),
"pipeline.continue_on_task_failure",
)
stages_raw = pipeline_raw.get("stages")
if not isinstance(stages_raw, list) or not stages_raw:
@ -254,7 +259,11 @@ def parse_config(raw: dict[str, Any], config_path: Path) -> NightShiftConfig:
project=project,
safety=safety,
agents=agents,
pipeline=PipelineConfig(max_task_retries=max_task_retries, stages=tuple(stages)),
pipeline=PipelineConfig(
max_task_retries=max_task_retries,
stages=tuple(stages),
continue_on_task_failure=continue_on_task_failure,
),
)

89
nightshift/git.py Normal file
View File

@ -0,0 +1,89 @@
"""Git safety and diff artifact helpers."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import subprocess
from .artifacts import ArtifactStore
from .errors import SafetyError
@dataclass(frozen=True)
class GitCommandResult:
available: bool
exit_code: int
stdout: str
stderr: str
def run_git(project_root: Path, args: list[str], timeout_seconds: int = 15) -> GitCommandResult:
try:
completed = subprocess.run(
["git", *args],
cwd=project_root,
capture_output=True,
text=True,
timeout=timeout_seconds,
)
except (OSError, subprocess.TimeoutExpired) as exc:
return GitCommandResult(False, -1, "", str(exc))
return GitCommandResult(completed.returncode == 0, completed.returncode, completed.stdout, completed.stderr)
def get_git_status(project_root: Path) -> GitCommandResult:
return run_git(project_root, ["status", "--short"])
def ensure_clean_worktree(project_root: Path, require_clean: bool) -> None:
if not require_clean:
return
status = get_git_status(project_root)
if not status.available:
raise SafetyError(
"Safety error: clean worktree is required, but git status could not be read: "
f"{status.stderr.strip() or 'unknown git error'}"
)
if status.stdout.strip():
raise SafetyError("Safety error: clean worktree is required, but repository is dirty.")
def write_git_artifacts(artifacts: ArtifactStore, task_id: str, when: str) -> Path:
status = get_git_status(artifacts.project_root)
content = format_git_status(status, when)
return artifacts.write_stage_output(task_id, f"git-status-{when}.txt", content)
def write_diff_artifact(artifacts: ArtifactStore, task_id: str) -> Path:
diff = run_git(artifacts.project_root, ["diff", "--binary"], timeout_seconds=30)
if not diff.available:
content = "Git diff unavailable.\n\n" + (diff.stderr or "")
elif diff.stdout:
content = diff.stdout
else:
content = "No tracked-file diff detected.\n"
return artifacts.write_stage_output(task_id, "diff.patch", content)
def format_git_status(status: GitCommandResult, when: str) -> str:
lines = [
f"# Git Status {when}",
"",
f"Available: {str(status.available).lower()}",
f"Exit code: {status.exit_code}",
"",
"## stdout",
"",
"```text",
status.stdout.rstrip(),
"```",
"",
"## stderr",
"",
"```text",
status.stderr.rstrip(),
"```",
"",
]
return "\n".join(lines)

View File

@ -12,9 +12,10 @@ from .config import COMMAND_STAGE_TYPES, NightShiftConfig, StageConfig
from .context import ContextManager
from .errors import PipelineError
from .errors import NightShiftError
from .git import ensure_clean_worktree, write_diff_artifact, write_git_artifacts
from .reports import ReportGenerator
from .stages import StageResult
from .tasks import Task
from .tasks import Task, mark_task_completed
@dataclass(frozen=True)
@ -27,6 +28,15 @@ class PipelineResult:
reason: str
@dataclass(frozen=True)
class MultiTaskResult:
status: str
task_results: tuple[PipelineResult, ...]
completed_count: int
failed_count: int
reason: str
class PipelineRunner:
"""Execute configured stages for one task."""
@ -55,9 +65,11 @@ class PipelineRunner:
)
def run_task(self, task: Task) -> PipelineResult:
ensure_clean_worktree(self.config.project.root, self.config.safety.require_clean_worktree)
self.artifacts.initialize_run()
self.artifacts.write_config_snapshot(self.config.path)
self.artifacts.write_task_snapshot(task)
write_git_artifacts(self.artifacts, task.id, "before")
self.context.ensure_project_context()
self.context.create_task_context(task)
@ -133,6 +145,20 @@ class PipelineRunner:
if result.context_update
],
)
completion_changed = False
if final_status == "complete":
completion_changed = mark_task_completed(
self.config.project.root,
self.config.project.task_file,
task.id,
)
self.artifacts.write_stage_output(
task.id,
"task-completion.md",
format_task_completion(task, final_status, completion_changed),
)
write_git_artifacts(self.artifacts, task.id, "after")
write_diff_artifact(self.artifacts, task.id)
self.reports.write_reports(
task,
final_status,
@ -151,6 +177,59 @@ class PipelineRunner:
reason=final_reason,
)
def run_tasks(self, tasks: list[Task] | tuple[Task, ...]) -> MultiTaskResult:
self.artifacts.initialize_run()
results: list[PipelineResult] = []
known_ids = {task.id for task in tasks}
completed_ids = {task.id for task in tasks if task.completed}
for task in tasks:
if task.completed:
continue
missing_refs = [dependency for dependency in task.dependencies if dependency not in known_ids]
incomplete_deps = [
dependency for dependency in task.dependencies if dependency in known_ids and dependency not in completed_ids
]
if missing_refs or incomplete_deps:
reason_parts = []
if missing_refs:
reason_parts.append(f"missing dependencies: {', '.join(missing_refs)}")
if incomplete_deps:
reason_parts.append(f"incomplete dependencies: {', '.join(incomplete_deps)}")
blocked = PipelineResult(
task_id=task.id,
status="blocked",
retry_count=0,
stage_results=(),
artifact_dir="",
reason="Task blocked by " + "; ".join(reason_parts),
)
results.append(blocked)
if not self.config.pipeline.continue_on_task_failure:
break
continue
result = self.run_task(task)
results.append(result)
if result.status == "complete":
completed_ids.add(task.id)
if result.status != "complete" and not self.config.pipeline.continue_on_task_failure:
break
completed_count = sum(1 for result in results if result.status == "complete")
failed_count = sum(1 for result in results if result.status != "complete")
status = "complete" if failed_count == 0 else "failed"
reason = "All selected tasks completed." if status == "complete" else "One or more tasks failed."
self.artifacts.run_summary_path.write_text(
format_aggregate_run_summary(results, status, reason),
encoding="utf-8",
)
return MultiTaskResult(
status=status,
task_results=tuple(results),
completed_count=completed_count,
failed_count=failed_count,
reason=reason,
)
def _run_stage(
self,
stage: StageConfig,
@ -217,3 +296,40 @@ def format_summary_stage(
"",
]
)
def format_task_completion(task: Task, status: str, changed: bool) -> str:
return "\n".join(
[
"# Task Completion",
"",
f"Task: `{task.id}`",
f"Pipeline status: {status}",
f"Marked complete: {str(changed).lower()}",
"",
]
)
def format_aggregate_run_summary(results: list[PipelineResult], status: str, reason: str) -> str:
lines = [
"# Run Summary",
"",
f"Status: {status}",
f"Reason: {reason}",
f"Tasks run: {len(results)}",
f"Completed tasks: {sum(1 for result in results if result.status == 'complete')}",
f"Failed tasks: {sum(1 for result in results if result.status != 'complete')}",
"",
"## Tasks",
"",
]
if not results:
lines.append("- None")
for result in results:
lines.append(
f"- `{result.task_id}`: {result.status} "
f"(retries: {result.retry_count}) - {result.reason}"
)
lines.append("")
return "\n".join(lines)

67
nightshift/status.py Normal file
View File

@ -0,0 +1,67 @@
"""Project status inspection."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from .config import NightShiftConfig
from .tasks import Task, dependency_problems, select_next_runnable_task
@dataclass(frozen=True)
class ProjectStatus:
config_path: Path
project_root: Path
task_count: int
completed_count: int
incomplete_count: int
next_task_id: str | None
latest_run_dir: Path | None
warnings: tuple[str, ...]
def build_status(config: NightShiftConfig, tasks: list[Task]) -> ProjectStatus:
latest = latest_run_dir(config.project.root / config.project.artifact_dir / "runs")
warnings = dependency_problems(tasks)
try:
next_task = select_next_runnable_task(tasks)
next_task_id = next_task.id
except Exception:
next_task_id = None
completed = sum(1 for task in tasks if task.completed)
return ProjectStatus(
config_path=config.path,
project_root=config.project.root,
task_count=len(tasks),
completed_count=completed,
incomplete_count=len(tasks) - completed,
next_task_id=next_task_id,
latest_run_dir=latest,
warnings=tuple(warnings),
)
def latest_run_dir(runs_dir: Path) -> Path | None:
if not runs_dir.exists() or not runs_dir.is_dir():
return None
candidates = [path for path in runs_dir.iterdir() if path.is_dir()]
if not candidates:
return None
return max(candidates, key=lambda path: path.stat().st_mtime)
def format_status(status: ProjectStatus) -> str:
lines = [
f"Config: {status.config_path}",
f"Project root: {status.project_root}",
f"Tasks: {status.task_count}",
f"Completed tasks: {status.completed_count}",
f"Incomplete tasks: {status.incomplete_count}",
f"Next task: {status.next_task_id or '<none>'}",
f"Latest run: {status.latest_run_dir or '<none>'}",
]
if status.warnings:
lines.append("Warnings:")
lines.extend(f"- {warning}" for warning in status.warnings)
return "\n".join(lines)

View File

@ -41,6 +41,13 @@ def parse_task_file(project_root: str | Path, task_file: str | Path) -> list[Tas
return parse_tasks(path.read_text(encoding="utf-8"))
def task_file_path(project_root: str | Path, task_file: str | Path) -> Path:
try:
return resolve_inside_root(project_root, task_file, "task file")
except SafetyError as exc:
raise TaskError(str(exc)) from exc
def parse_tasks(markdown: str) -> list[Task]:
"""Parse NightShift's documented markdown checklist task format."""
@ -114,6 +121,24 @@ def select_next_incomplete_task(tasks: list[Task] | tuple[Task, ...]) -> Task:
raise TaskError("Task error: no incomplete tasks found.")
def select_next_runnable_task(tasks: list[Task] | tuple[Task, ...]) -> Task:
"""Return the first incomplete task whose dependencies are complete."""
completed = {task.id for task in tasks if task.completed}
blocked: list[str] = []
for task in tasks:
if task.completed:
continue
missing = [dependency for dependency in task.dependencies if dependency not in completed]
if missing:
blocked.append(f"{task.id} blocked by {', '.join(missing)}")
continue
return task
if blocked:
raise TaskError("Task error: no runnable incomplete tasks. " + "; ".join(blocked))
raise TaskError("Task error: no incomplete tasks found.")
def select_task_by_id(tasks: list[Task] | tuple[Task, ...], task_id: str) -> Task:
"""Return a task by id."""
@ -124,6 +149,58 @@ def select_task_by_id(tasks: list[Task] | tuple[Task, ...], task_id: str) -> Tas
raise TaskError(f"Task error: unknown task id '{task_id}'. Available tasks: {available}.")
def ensure_dependencies_satisfied(tasks: list[Task] | tuple[Task, ...], task: Task) -> None:
task_ids = {candidate.id for candidate in tasks}
completed = {candidate.id for candidate in tasks if candidate.completed}
missing_refs = [dependency for dependency in task.dependencies if dependency not in task_ids]
if missing_refs:
raise TaskError(
f"Task error: task '{task.id}' references missing dependencies: "
f"{', '.join(missing_refs)}."
)
incomplete = [dependency for dependency in task.dependencies if dependency not in completed]
if incomplete:
raise TaskError(
f"Task error: task '{task.id}' is blocked by incomplete dependencies: "
f"{', '.join(incomplete)}."
)
def dependency_problems(tasks: list[Task] | tuple[Task, ...]) -> list[str]:
task_ids = {task.id for task in tasks}
problems: list[str] = []
for task in tasks:
for dependency in task.dependencies:
if dependency not in task_ids:
problems.append(f"Task '{task.id}' references missing dependency '{dependency}'.")
problems.extend(_cycle_problems(tasks))
return problems
def validate_task_dependencies(tasks: list[Task] | tuple[Task, ...]) -> None:
problems = dependency_problems(tasks)
if problems:
raise TaskError("Task dependency error: " + " ".join(problems))
def mark_task_completed(project_root: str | Path, task_file: str | Path, task_id: str) -> bool:
"""Mark a task complete in the markdown task file with a minimal line edit."""
path = task_file_path(project_root, task_file)
if not path.exists():
raise TaskError(f"Task error: task file does not exist: {path}")
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
for index, line in enumerate(lines):
match = TASK_HEADER_RE.match(line.rstrip("\r\n"))
if match and match.group("id") == task_id:
if match.group("mark").lower() == "x":
return False
lines[index] = re.sub(r"\[[ ]\]", "[x]", line, count=1)
path.write_text("".join(lines), encoding="utf-8")
return True
raise TaskError(f"Task error: cannot mark unknown task complete: {task_id}.")
def _extract_section(block: list[str], section_name: str) -> str:
start = _find_section_index(block, section_name)
if start is None:
@ -161,3 +238,28 @@ def _find_section_index(block: list[str], section_name: str) -> int | None:
if line.strip().lower() == expected:
return index
return None
def _cycle_problems(tasks: list[Task] | tuple[Task, ...]) -> list[str]:
graph = {task.id: tuple(dep for dep in task.dependencies if dep in {item.id for item in tasks}) for task in tasks}
visiting: set[str] = set()
visited: set[str] = set()
problems: list[str] = []
def visit(task_id: str, path: list[str]) -> None:
if task_id in visited:
return
if task_id in visiting:
cycle_start = path.index(task_id) if task_id in path else 0
cycle = " -> ".join(path[cycle_start:] + [task_id])
problems.append(f"Dependency cycle detected: {cycle}.")
return
visiting.add(task_id)
for dependency in graph.get(task_id, ()):
visit(dependency, path + [dependency])
visiting.remove(task_id)
visited.add(task_id)
for task_id in graph:
visit(task_id, [task_id])
return problems

43
tests/test_git.py Normal file
View File

@ -0,0 +1,43 @@
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from nightshift.artifacts import ArtifactStore
from nightshift.errors import SafetyError
from nightshift.git import ensure_clean_worktree, write_diff_artifact, write_git_artifacts
def git_available() -> bool:
return shutil.which("git") is not None
@unittest.skipUnless(git_available(), "git is not available")
class GitSafetyTests(unittest.TestCase):
def test_clean_worktree_requirement_blocks_dirty_repo(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
subprocess.run(["git", "init"], cwd=root, check=True, capture_output=True, text=True)
(root / "file.txt").write_text("dirty", encoding="utf-8")
with self.assertRaisesRegex(SafetyError, "repository is dirty"):
ensure_clean_worktree(root, True)
def test_git_artifacts_are_written_for_repo(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
subprocess.run(["git", "init"], cwd=root, check=True, capture_output=True, text=True)
(root / "file.txt").write_text("dirty", encoding="utf-8")
artifacts = ArtifactStore(root, ".nightshift", run_id="test-run")
status_path = write_git_artifacts(artifacts, "TASK-001", "before")
diff_path = write_diff_artifact(artifacts, "TASK-001")
self.assertTrue(status_path.exists())
self.assertTrue(diff_path.exists())
self.assertIn("Git Status before", status_path.read_text(encoding="utf-8"))
if __name__ == "__main__":
unittest.main()

View File

@ -145,6 +145,89 @@ class PipelineRunnerTests(unittest.TestCase):
(root / ".nightshift" / "runs" / "test-run" / "tasks" / task.id / "final-notes.md").exists()
)
def test_successful_task_is_marked_complete_and_git_artifacts_exist(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
_write_common_files(root)
stages = (
StageConfig(id="plan", type="agent", agent="planner", output="plan.md"),
)
config = make_config(root, stages)
runner = PipelineRunner(config, ArtifactStore(root, ".nightshift", run_id="test-run"))
task = parse_tasks(TASK_MD)[0]
result = runner.run_task(task)
self.assertEqual(result.status, "complete")
self.assertIn("- [x] TASK-001", (root / "tasks.md").read_text(encoding="utf-8"))
task_dir = root / ".nightshift" / "runs" / "test-run" / "tasks" / task.id
self.assertTrue((task_dir / "task-completion.md").exists())
self.assertTrue((task_dir / "git-status-before.txt").exists())
self.assertTrue((task_dir / "git-status-after.txt").exists())
self.assertTrue((task_dir / "diff.patch").exists())
def test_multi_task_run_writes_aggregate_summary_and_stops_on_failure(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
_write_common_files(root)
tasks_md = TASK_MD + """
- [ ] TASK-002: Second task
Description:
Should not run after failure.
Acceptance Criteria:
- skipped
"""
(root / "tasks.md").write_text(tasks_md, encoding="utf-8")
stages = (
StageConfig(
id="test",
type="command",
commands=('python -c "print(\'missing\')"',),
output="../bad.txt",
),
)
config = make_config(root, stages, max_retries=0)
runner = PipelineRunner(config, ArtifactStore(root, ".nightshift", run_id="test-run"))
tasks = parse_tasks(tasks_md)
result = runner.run_tasks(tasks)
self.assertEqual(result.status, "failed")
self.assertEqual(len(result.task_results), 1)
summary = (root / ".nightshift" / "runs" / "test-run" / "run-summary.md").read_text(encoding="utf-8")
self.assertIn("Tasks run: 1", summary)
def test_multi_task_run_blocks_incomplete_dependency(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
_write_common_files(root)
tasks_md = """# Tasks
- [ ] TASK-001: Blocked
Dependencies:
- TASK-002
Acceptance Criteria:
- blocked
- [ ] TASK-002: Later
Acceptance Criteria:
- later
"""
(root / "tasks.md").write_text(tasks_md, encoding="utf-8")
config = make_config(root, (), max_retries=0)
runner = PipelineRunner(config, ArtifactStore(root, ".nightshift", run_id="test-run"))
result = runner.run_tasks(parse_tasks(tasks_md))
self.assertEqual(result.status, "failed")
self.assertEqual(result.task_results[0].status, "blocked")
def _write_common_files(root: Path) -> None:
(root / "nightshift.yaml").write_text("project:\n name: test\n", encoding="utf-8")

33
tests/test_status.py Normal file
View File

@ -0,0 +1,33 @@
from pathlib import Path
import tempfile
import unittest
from nightshift.artifacts import ArtifactStore
from nightshift.config import load_config
from nightshift.init import init_project
from nightshift.status import build_status, format_status
from nightshift.tasks import parse_task_file
class StatusTests(unittest.TestCase):
def test_status_reports_counts_and_latest_run(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
init_project(root)
ArtifactStore(root, ".nightshift", run_id="run-a").initialize_run()
config = load_config(root / "nightshift.yaml")
tasks = parse_task_file(config.project.root, config.project.task_file)
status = build_status(config, tasks)
output = format_status(status)
self.assertEqual(status.task_count, 1)
self.assertEqual(status.incomplete_count, 1)
self.assertEqual(status.next_task_id, "TASK-001")
self.assertIsNotNone(status.latest_run_dir)
self.assertIn("Project root:", output)
self.assertIn("Latest run:", output)
if __name__ == "__main__":
unittest.main()

View File

@ -4,10 +4,15 @@ import unittest
from nightshift.errors import TaskError
from nightshift.tasks import (
dependency_problems,
ensure_dependencies_satisfied,
mark_task_completed,
parse_task_file,
parse_tasks,
select_next_incomplete_task,
select_next_runnable_task,
select_task_by_id,
validate_task_dependencies,
)
@ -107,6 +112,61 @@ No acceptance criteria.
with self.assertRaisesRegex(TaskError, "no tasks found"):
parse_tasks("# Tasks\n\nNothing here.\n")
def test_dependency_blocks_specific_task_selection(self) -> None:
tasks = parse_tasks(TASKS_MD.replace("[x] TASK-001", "[ ] TASK-001"))
with self.assertRaisesRegex(TaskError, "blocked by incomplete dependencies"):
ensure_dependencies_satisfied(tasks, tasks[1])
def test_select_next_runnable_skips_blocked_tasks(self) -> None:
markdown = TASKS_MD.replace("[x] TASK-001", "[ ] TASK-001")
tasks = parse_tasks(markdown)
selected = select_next_runnable_task(tasks)
self.assertEqual(selected.id, "TASK-001")
def test_dependency_validation_reports_missing_and_cycles(self) -> None:
markdown = """# Tasks
- [ ] TASK-001: First
Dependencies:
- TASK-002
Acceptance Criteria:
- ok
- [ ] TASK-002: Second
Dependencies:
- TASK-001
- TASK-999
Acceptance Criteria:
- ok
"""
tasks = parse_tasks(markdown)
problems = dependency_problems(tasks)
self.assertTrue(any("TASK-999" in problem for problem in problems))
self.assertTrue(any("cycle" in problem.lower() for problem in problems))
with self.assertRaisesRegex(TaskError, "Task dependency error"):
validate_task_dependencies(tasks)
def test_mark_task_completed_updates_only_target_line(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
task_path = root / "tasks.md"
task_path.write_text(TASKS_MD, encoding="utf-8")
changed = mark_task_completed(root, "tasks.md", "TASK-002")
self.assertTrue(changed)
content = task_path.read_text(encoding="utf-8")
self.assertIn("- [x] TASK-002: Add artifact directory creation", content)
if __name__ == "__main__":
unittest.main()