steerdev 1.1.45__tar.gz → 1.1.48__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.
Files changed (134) hide show
  1. {steerdev-1.1.45 → steerdev-1.1.48}/PKG-INFO +1 -1
  2. {steerdev-1.1.45 → steerdev-1.1.48}/pyproject.toml +1 -1
  3. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/agent_loop.py +99 -1
  4. steerdev-1.1.48/tests/test_agent_loop_wave_pr.py +180 -0
  5. {steerdev-1.1.45 → steerdev-1.1.48}/.github/workflows/pre-commit.yml +0 -0
  6. {steerdev-1.1.45 → steerdev-1.1.48}/.github/workflows/publish.yml +0 -0
  7. {steerdev-1.1.45 → steerdev-1.1.48}/.gitignore +0 -0
  8. {steerdev-1.1.45 → steerdev-1.1.48}/.pre-commit-config.yaml +0 -0
  9. {steerdev-1.1.45 → steerdev-1.1.48}/AGENTS.md +0 -0
  10. {steerdev-1.1.45 → steerdev-1.1.48}/CLAUDE.md +0 -0
  11. {steerdev-1.1.45 → steerdev-1.1.48}/README.md +0 -0
  12. {steerdev-1.1.45 → steerdev-1.1.48}/docs/superpowers/plans/2026-06-05-wave-id-implies-unlimited-max-tasks.md +0 -0
  13. {steerdev-1.1.45 → steerdev-1.1.48}/docs/superpowers/specs/2026-06-05-wave-id-implies-unlimited-max-tasks-design.md +0 -0
  14. {steerdev-1.1.45 → steerdev-1.1.48}/scripts/pre-commit-version-bump.sh +0 -0
  15. {steerdev-1.1.45 → steerdev-1.1.48}/snapshots/steerdev-agent-v1/Dockerfile +0 -0
  16. {steerdev-1.1.45 → steerdev-1.1.48}/snapshots/steerdev-agent-v1/start-agent.sh +0 -0
  17. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/__init__.py +0 -0
  18. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/__init__.py +0 -0
  19. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/activity.py +0 -0
  20. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/agents.py +0 -0
  21. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/canals.py +0 -0
  22. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/client.py +0 -0
  23. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/commands.py +0 -0
  24. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/configs.py +0 -0
  25. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/context.py +0 -0
  26. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/events.py +0 -0
  27. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/hooks.py +0 -0
  28. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/implementation_plan.py +0 -0
  29. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/merger.py +0 -0
  30. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/messages.py +0 -0
  31. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/prd.py +0 -0
  32. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/reports.py +0 -0
  33. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/runs.py +0 -0
  34. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/sessions.py +0 -0
  35. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/specs.py +0 -0
  36. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/tasks.py +0 -0
  37. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/workflow_runs.py +0 -0
  38. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/api/workflows.py +0 -0
  39. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/cli.py +0 -0
  40. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/config/__init__.py +0 -0
  41. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/config/models.py +0 -0
  42. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/config/platform.py +0 -0
  43. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/config/settings.py +0 -0
  44. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/evidence.py +0 -0
  45. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/evidence_assets.py +0 -0
  46. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/executor/__init__.py +0 -0
  47. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/executor/base.py +0 -0
  48. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/executor/claude.py +0 -0
  49. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/executor/stream.py +0 -0
  50. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/git/__init__.py +0 -0
  51. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/git/commit.py +0 -0
  52. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/git/pr.py +0 -0
  53. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/git_ops.py +0 -0
  54. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/handlers/__init__.py +0 -0
  55. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/handlers/prd.py +0 -0
  56. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/integration.py +0 -0
  57. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/prompt/__init__.py +0 -0
  58. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/prompt/builder.py +0 -0
  59. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/prompt/templates.py +0 -0
  60. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/prompt/workflow_template.py +0 -0
  61. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/py.typed +0 -0
  62. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/retry.py +0 -0
  63. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/runner.py +0 -0
  64. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/__init__.py +0 -0
  65. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/claude_setup.py +0 -0
  66. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/repo_setup.py +0 -0
  67. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/templates/ci/canal-integration.yml +0 -0
  68. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/templates/claude_md_section.md +0 -0
  69. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/templates/settings.json +0 -0
  70. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/templates/skills/steerdev-activity-skill/SKILL.md +0 -0
  71. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/templates/skills/steerdev-canal-workflow-skill/SKILL.md +0 -0
  72. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/templates/skills/steerdev-context-skill/SKILL.md +0 -0
  73. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/templates/skills/steerdev-git-workflow-skill/SKILL.md +0 -0
  74. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/templates/skills/steerdev-merge-into-canal-skill/SKILL.md +0 -0
  75. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/templates/skills/steerdev-progress-logging-skill/SKILL.md +0 -0
  76. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/templates/skills/steerdev-single-task-merge-skill/SKILL.md +0 -0
  77. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/templates/skills/steerdev-specs-management-skill/SKILL.md +0 -0
  78. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/templates/skills/steerdev-task-management-skill/SKILL.md +0 -0
  79. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/templates/skills/steerdev-wave-tasks-merge-skill/SKILL.md +0 -0
  80. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/templates/steerdev.yaml +0 -0
  81. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/setup/templates/worktrunk.config.toml +0 -0
  82. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/update_check.py +0 -0
  83. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/version.py +0 -0
  84. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/workflow/__init__.py +0 -0
  85. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/workflow/context.py +0 -0
  86. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/workflow/executor.py +0 -0
  87. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/workflow/memory.py +0 -0
  88. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/workspace/__init__.py +0 -0
  89. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/workspace/preparation.py +0 -0
  90. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/workspace/project_manager.py +0 -0
  91. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/workspace/tool_detection.py +0 -0
  92. {steerdev-1.1.45 → steerdev-1.1.48}/src/steerdev_agent/worktree.py +0 -0
  93. {steerdev-1.1.45 → steerdev-1.1.48}/tests/__init__.py +0 -0
  94. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_agent_loop.py +0 -0
  95. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_agent_loop_extended.py +0 -0
  96. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_agents_api.py +0 -0
  97. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_api_client.py +0 -0
  98. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_claude_executor.py +0 -0
  99. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_claude_setup.py +0 -0
  100. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_client_methods.py +0 -0
  101. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_commands_api.py +0 -0
  102. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_config.py +0 -0
  103. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_config_extended.py +0 -0
  104. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_conflict_mitigation.py +0 -0
  105. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_context_search.py +0 -0
  106. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_executor.py +0 -0
  107. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_git_commit.py +0 -0
  108. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_git_ops.py +0 -0
  109. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_git_pr.py +0 -0
  110. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_hooks_post_tool_use.py +0 -0
  111. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_platform_config.py +0 -0
  112. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_preparation.py +0 -0
  113. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_preparation_provenance.py +0 -0
  114. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_prompt.py +0 -0
  115. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_reports_client.py +0 -0
  116. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_resolve_executor_config.py +0 -0
  117. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_resolve_max_tasks.py +0 -0
  118. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_retry.py +0 -0
  119. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_runner_boundary_commit.py +0 -0
  120. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_runner_merge_modes.py +0 -0
  121. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_runner_wave_pr.py +0 -0
  122. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_runner_worktrees.py +0 -0
  123. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_stream_parser.py +0 -0
  124. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_tasks.py +0 -0
  125. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_tasks_update_branch.py +0 -0
  126. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_version.py +0 -0
  127. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_wave_scoped_fetch.py +0 -0
  128. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_workflow_context.py +0 -0
  129. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_workflow_memory.py +0 -0
  130. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_workflow_prompt_template.py +0 -0
  131. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_workflow_runs_api.py +0 -0
  132. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_workspace.py +0 -0
  133. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_workspace_extended.py +0 -0
  134. {steerdev-1.1.45 → steerdev-1.1.48}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerdev
3
- Version: 1.1.45
3
+ Version: 1.1.48
4
4
  Summary: Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting
5
5
  Project-URL: Homepage, https://github.com/pentoai/steerdev-agent
6
6
  Project-URL: Repository, https://github.com/pentoai/steerdev-agent
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "steerdev"
3
- version = "1.1.45"
3
+ version = "1.1.48"
4
4
  description = "Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -18,8 +18,9 @@ import asyncio
18
18
  import contextlib
19
19
  import signal
20
20
  import socket
21
+ from dataclasses import dataclass, field
21
22
  from pathlib import Path
22
- from typing import TYPE_CHECKING
23
+ from typing import TYPE_CHECKING, Any
23
24
 
24
25
  import httpx
25
26
  from loguru import logger
@@ -28,6 +29,7 @@ from rich.panel import Panel
28
29
 
29
30
  if TYPE_CHECKING:
30
31
  from steerdev_agent.api.agents import AgentsClient, ProjectAssignment
32
+ from steerdev_agent.prompt.builder import WaveContext
31
33
  from steerdev_agent.workspace.project_manager import ProjectManager
32
34
 
33
35
  from steerdev_agent.api.commands import CommandResponse, CommandsClient
@@ -54,6 +56,15 @@ class AgentLoopError(Exception):
54
56
  """Error during agent loop execution."""
55
57
 
56
58
 
59
+ @dataclass
60
+ class _WavePrTracking:
61
+ """Per-project wave PR safety-net state (mirrors Runner.run's tracking)."""
62
+
63
+ wave_ctx: WaveContext
64
+ completed_tasks: list[dict[str, Any]] = field(default_factory=list)
65
+ working_dir: str | None = None
66
+
67
+
57
68
  # ---------------------------------------------------------------------------
58
69
  # Shared execution logic
59
70
  # ---------------------------------------------------------------------------
@@ -92,6 +103,7 @@ class CommandExecutor:
92
103
  _commands_succeeded: int
93
104
  _commands_failed: int
94
105
  _is_busy: bool
106
+ _wave_pr_state: dict[str, _WavePrTracking]
95
107
 
96
108
  async def _execute_command(
97
109
  self,
@@ -273,6 +285,90 @@ class CommandExecutor:
273
285
  finally:
274
286
  await runs_client.close()
275
287
 
288
+ # Wave PR safety net (mirrors Runner.run): once the wave has no more
289
+ # pending tasks, make sure its PR exists.
290
+ await self._maybe_ensure_wave_pr(
291
+ project_id,
292
+ task,
293
+ wave_ctx,
294
+ result,
295
+ fallback_working_dir=str(command.working_directory or working_directory),
296
+ )
297
+
298
+ async def _maybe_ensure_wave_pr(
299
+ self,
300
+ project_id: str,
301
+ task: dict[str, Any],
302
+ wave_ctx: WaveContext | None,
303
+ result: dict[str, Any],
304
+ fallback_working_dir: str,
305
+ ) -> None:
306
+ """Track completed wave tasks across commands and create the wave PR.
307
+
308
+ Command-driven counterpart of Runner.run's wave PR safety net: each
309
+ command executes a single task, so wave completion is detected by
310
+ peeking at the wave queue after every successful wave task — no task
311
+ left (or scheduling moved to a different wave) means the wave is done
312
+ and its PR must exist. Best-effort: never raises.
313
+ """
314
+ if wave_ctx is None or not result.get("success"):
315
+ return
316
+
317
+ from steerdev_agent.runner import extract_task_and_wave_context, wave_identity
318
+
319
+ try:
320
+ state = self._wave_pr_state.get(project_id)
321
+ if state is not None and wave_identity(state.wave_ctx) != wave_identity(wave_ctx):
322
+ # Scheduling moved on to a new wave: the tracked one completed.
323
+ self._flush_wave_pr(project_id, fallback_working_dir)
324
+ state = None
325
+ if state is None:
326
+ state = _WavePrTracking(wave_ctx=wave_ctx)
327
+ self._wave_pr_state[project_id] = state
328
+ state.completed_tasks.append({"id": task.get("id"), "title": task.get("title")})
329
+ if result.get("working_dir"):
330
+ state.working_dir = result["working_dir"]
331
+
332
+ try:
333
+ with TasksClient(api_key=self._api_key) as tasks_client:
334
+ wave_response = tasks_client.get_next_wave(
335
+ project_id=project_id, agent_id=self._agent_id
336
+ )
337
+ next_task, next_wave_ctx, _ = extract_task_and_wave_context(wave_response)
338
+ except Exception:
339
+ logger.debug("Wave PR safety net: wave peek failed, skipping", exc_info=True)
340
+ return
341
+
342
+ if (
343
+ next_task is None
344
+ or next_wave_ctx is None
345
+ or wave_identity(next_wave_ctx) != wave_identity(wave_ctx)
346
+ ):
347
+ self._flush_wave_pr(project_id, fallback_working_dir)
348
+ except Exception: # broad catch intentional: safety net must never break the loop
349
+ logger.warning("Wave PR safety net failed (non-fatal)", exc_info=True)
350
+
351
+ def _flush_wave_pr(self, project_id: str, fallback_working_dir: str) -> None:
352
+ """Create the tracked wave's PR if the agent didn't. Never raises."""
353
+ state = self._wave_pr_state.pop(project_id, None)
354
+ if state is None:
355
+ return
356
+ try:
357
+ from steerdev_agent.git.pr import ensure_wave_pr
358
+
359
+ status = ensure_wave_pr(
360
+ state.working_dir or fallback_working_dir,
361
+ state.wave_ctx,
362
+ state.completed_tasks,
363
+ api_key=self._api_key,
364
+ )
365
+ if status == "created":
366
+ console.print("[green]Wave PR created (safety net)[/green]")
367
+ else:
368
+ logger.info(f"Wave PR safety net: {status}")
369
+ except Exception: # broad catch intentional: safety net must never break the loop
370
+ logger.warning("Wave PR safety net failed (non-fatal)", exc_info=True)
371
+
276
372
  async def _execute_prompt_command(
277
373
  self,
278
374
  command: CommandResponse,
@@ -530,6 +626,7 @@ class AgentLoop(CommandExecutor):
530
626
  self._commands_executed = 0
531
627
  self._commands_succeeded = 0
532
628
  self._commands_failed = 0
629
+ self._wave_pr_state: dict[str, _WavePrTracking] = {}
533
630
 
534
631
  # Components
535
632
  self._commands_client: CommandsClient | None = None
@@ -921,6 +1018,7 @@ class WorkspaceAgentLoop(CommandExecutor):
921
1018
  self._commands_executed = 0
922
1019
  self._commands_succeeded = 0
923
1020
  self._commands_failed = 0
1021
+ self._wave_pr_state: dict[str, _WavePrTracking] = {}
924
1022
 
925
1023
  # Components
926
1024
  self._commands_client: CommandsClient | None = None
@@ -0,0 +1,180 @@
1
+ """Tests for the agent loop's wave PR safety net (command-driven counterpart
2
+ of the runner's Runner.run safety net)."""
3
+
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from steerdev_agent.agent_loop import AgentLoop, _WavePrTracking
9
+ from tests.test_runner_merge_modes import _make_wave_context
10
+
11
+
12
+ def _make_loop() -> AgentLoop:
13
+ return AgentLoop(project_id="p", agent_name="a", api_key="k")
14
+
15
+
16
+ def _make_wave_response(wave_name: str = "wave-a1b2c3", next_task: dict | None = None) -> dict:
17
+ return {
18
+ "wave": {"wave_number": 3, "total_waves": 5, "name": wave_name},
19
+ "context": {"next_task": next_task},
20
+ }
21
+
22
+
23
+ def _patch_wave_peek(wave_response):
24
+ """Patch TasksClient so get_next_wave returns the given response."""
25
+ client = MagicMock()
26
+ client.__enter__.return_value = client
27
+ client.get_next_wave.return_value = wave_response
28
+ return patch("steerdev_agent.agent_loop.TasksClient", return_value=client)
29
+
30
+
31
+ class TestMaybeEnsureWavePr:
32
+ @pytest.mark.asyncio
33
+ async def test_noop_without_wave_context(self):
34
+ loop = _make_loop()
35
+ with patch("steerdev_agent.git.pr.ensure_wave_pr") as ensure:
36
+ await loop._maybe_ensure_wave_pr("p", {"id": "t1"}, None, {"success": True}, "/repo")
37
+ ensure.assert_not_called()
38
+ assert loop._wave_pr_state == {}
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_noop_on_failed_task(self):
42
+ loop = _make_loop()
43
+ with patch("steerdev_agent.git.pr.ensure_wave_pr") as ensure:
44
+ await loop._maybe_ensure_wave_pr(
45
+ "p", {"id": "t1"}, _make_wave_context(), {"success": False}, "/repo"
46
+ )
47
+ ensure.assert_not_called()
48
+ assert loop._wave_pr_state == {}
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_accumulates_while_wave_has_pending_tasks(self):
52
+ loop = _make_loop()
53
+ ctx = _make_wave_context()
54
+ # Peek returns another task in the same wave → wave not done yet.
55
+ peek = _make_wave_response(next_task={"id": "t2", "title": "next"})
56
+ with (
57
+ _patch_wave_peek(peek),
58
+ patch("steerdev_agent.git.pr.ensure_wave_pr") as ensure,
59
+ ):
60
+ await loop._maybe_ensure_wave_pr(
61
+ "p", {"id": "t1", "title": "do work"}, ctx, {"success": True}, "/repo"
62
+ )
63
+ ensure.assert_not_called()
64
+ assert loop._wave_pr_state["p"].completed_tasks == [{"id": "t1", "title": "do work"}]
65
+
66
+ @pytest.mark.asyncio
67
+ async def test_creates_pr_when_wave_exhausted(self):
68
+ loop = _make_loop()
69
+ ctx = _make_wave_context()
70
+ with (
71
+ _patch_wave_peek(None),
72
+ patch("steerdev_agent.git.pr.ensure_wave_pr", return_value="created") as ensure,
73
+ ):
74
+ await loop._maybe_ensure_wave_pr(
75
+ "p",
76
+ {"id": "t1", "title": "do work"},
77
+ ctx,
78
+ {"success": True, "working_dir": "/repo/.worktrees/wave-a1b2c3"},
79
+ "/repo",
80
+ )
81
+ ensure.assert_called_once_with(
82
+ "/repo/.worktrees/wave-a1b2c3",
83
+ ctx,
84
+ [{"id": "t1", "title": "do work"}],
85
+ api_key="k",
86
+ )
87
+ # State is reset so the next wave starts clean.
88
+ assert loop._wave_pr_state == {}
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_creates_pr_when_scheduling_moves_to_next_wave(self):
92
+ loop = _make_loop()
93
+ ctx = _make_wave_context()
94
+ peek = _make_wave_response(wave_name="wave-next", next_task={"id": "t9"})
95
+ with (
96
+ _patch_wave_peek(peek),
97
+ patch("steerdev_agent.git.pr.ensure_wave_pr", return_value="created") as ensure,
98
+ ):
99
+ await loop._maybe_ensure_wave_pr(
100
+ "p", {"id": "t1", "title": "do work"}, ctx, {"success": True}, "/repo"
101
+ )
102
+ ensure.assert_called_once()
103
+ assert loop._wave_pr_state == {}
104
+
105
+ @pytest.mark.asyncio
106
+ async def test_flushes_previous_wave_when_new_wave_task_completes(self):
107
+ loop = _make_loop()
108
+ old_ctx = _make_wave_context()
109
+ new_ctx = _make_wave_context()
110
+ new_ctx.wave_name = "wave-next"
111
+ loop._wave_pr_state["p"] = _WavePrTracking(
112
+ wave_ctx=old_ctx, completed_tasks=[{"id": "t0", "title": "old"}]
113
+ )
114
+ # Peek says the new wave still has tasks → only the old wave flushes.
115
+ peek = _make_wave_response(wave_name="wave-next", next_task={"id": "t9"})
116
+ with (
117
+ _patch_wave_peek(peek),
118
+ patch("steerdev_agent.git.pr.ensure_wave_pr", return_value="created") as ensure,
119
+ ):
120
+ await loop._maybe_ensure_wave_pr(
121
+ "p", {"id": "t1", "title": "new work"}, new_ctx, {"success": True}, "/repo"
122
+ )
123
+ ensure.assert_called_once_with(
124
+ "/repo", old_ctx, [{"id": "t0", "title": "old"}], api_key="k"
125
+ )
126
+ assert loop._wave_pr_state["p"].wave_ctx is new_ctx
127
+ assert loop._wave_pr_state["p"].completed_tasks == [{"id": "t1", "title": "new work"}]
128
+
129
+ @pytest.mark.asyncio
130
+ async def test_peek_failure_keeps_state_and_skips(self):
131
+ loop = _make_loop()
132
+ ctx = _make_wave_context()
133
+ with (
134
+ patch("steerdev_agent.agent_loop.TasksClient", side_effect=RuntimeError("api down")),
135
+ patch("steerdev_agent.git.pr.ensure_wave_pr") as ensure,
136
+ ):
137
+ await loop._maybe_ensure_wave_pr(
138
+ "p", {"id": "t1", "title": "do work"}, ctx, {"success": True}, "/repo"
139
+ )
140
+ ensure.assert_not_called()
141
+ # Task is still tracked, so a later flush includes it.
142
+ assert loop._wave_pr_state["p"].completed_tasks == [{"id": "t1", "title": "do work"}]
143
+
144
+ @pytest.mark.asyncio
145
+ async def test_never_raises(self):
146
+ loop = _make_loop()
147
+ ctx = _make_wave_context()
148
+ with (
149
+ _patch_wave_peek(None),
150
+ patch("steerdev_agent.git.pr.ensure_wave_pr", side_effect=RuntimeError("boom")),
151
+ ):
152
+ await loop._maybe_ensure_wave_pr(
153
+ "p", {"id": "t1", "title": "do work"}, ctx, {"success": True}, "/repo"
154
+ ) # must not raise
155
+ assert loop._wave_pr_state == {}
156
+
157
+
158
+ class TestFlushWavePr:
159
+ def test_noop_without_tracked_state(self):
160
+ loop = _make_loop()
161
+ with patch("steerdev_agent.git.pr.ensure_wave_pr") as ensure:
162
+ loop._flush_wave_pr("p", "/repo")
163
+ ensure.assert_not_called()
164
+
165
+ def test_falls_back_to_command_working_dir(self):
166
+ loop = _make_loop()
167
+ loop._wave_pr_state["p"] = _WavePrTracking(wave_ctx=_make_wave_context())
168
+ with patch("steerdev_agent.git.pr.ensure_wave_pr", return_value="created") as ensure:
169
+ loop._flush_wave_pr("p", "/repo")
170
+ assert ensure.call_args.args[0] == "/repo"
171
+ assert loop._wave_pr_state == {}
172
+
173
+ def test_state_is_per_project(self):
174
+ loop = _make_loop()
175
+ loop._wave_pr_state["p1"] = _WavePrTracking(wave_ctx=_make_wave_context())
176
+ loop._wave_pr_state["p2"] = _WavePrTracking(wave_ctx=_make_wave_context())
177
+ with patch("steerdev_agent.git.pr.ensure_wave_pr", return_value="created"):
178
+ loop._flush_wave_pr("p1", "/repo")
179
+ assert "p1" not in loop._wave_pr_state
180
+ assert "p2" in loop._wave_pr_state
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes