codee-agent 0.7.0__tar.gz → 0.7.2__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 (96) hide show
  1. {codee_agent-0.7.0 → codee_agent-0.7.2}/PKG-INFO +1 -1
  2. {codee_agent-0.7.0 → codee_agent-0.7.2}/pyproject.toml +1 -1
  3. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/admin.py +23 -3
  4. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/admin_service.py +45 -6
  5. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/executor.py +8 -0
  6. codee_agent-0.7.2/src/codee/lib/runtime_control.py +21 -0
  7. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/test_admin_service.py +100 -44
  8. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/test_executor.py +21 -0
  9. {codee_agent-0.7.0 → codee_agent-0.7.2}/LICENSE +0 -0
  10. {codee_agent-0.7.0 → codee_agent-0.7.2}/README.md +0 -0
  11. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/.gitignore +0 -0
  12. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/__init__.py +0 -0
  13. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/admin_api.py +0 -0
  14. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/admin_cli.py +0 -0
  15. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/agent_cli.py +0 -0
  16. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/coding_agents.py +0 -0
  17. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/init_cli.py +0 -0
  18. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/lib/__init__.py +0 -0
  19. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/lib/claude_key_rotation.py +0 -0
  20. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/lib/cron_describe.py +0 -0
  21. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/lib/mcp_config.py +0 -0
  22. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/lib/runs_db.py +0 -0
  23. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/lib/test_claude_key_rotation.py +0 -0
  24. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/lib/test_mcp_config.py +0 -0
  25. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/lib/test_runs_db.py +0 -0
  26. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/lib/test_trigger_cron_skills.py +0 -0
  27. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/lib/test_trigger_issue_skills.py +0 -0
  28. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
  29. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/lib/trigger_cron_skills.py +0 -0
  30. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/lib/trigger_email_skills.py +0 -0
  31. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/lib/trigger_issue_skills.py +0 -0
  32. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/mail_server.py +0 -0
  33. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/setup_wizard.py +0 -0
  34. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/start_cli.py +0 -0
  35. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/tasks_providers.py +0 -0
  36. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/templates/AGENTS.md +0 -0
  37. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/templates/CLAUDE.md +0 -0
  38. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  39. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  40. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  41. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  42. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  43. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  44. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  45. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/templates/skills/story-security-reviewer/SKILL.md +0 -0
  46. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/templates/skills/task-code-reviewer/SKILL.md +0 -0
  47. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  48. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  49. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/templates/skills/task-security-reviewer/SKILL.md +0 -0
  50. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/test_admin_api.py +0 -0
  51. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/test_admin_cli.py +0 -0
  52. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/test_agent_cli.py +0 -0
  53. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/test_coding_agents.py +0 -0
  54. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/test_init_cli.py +0 -0
  55. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/test_memory_index.py +0 -0
  56. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/test_setup_wizard.py +0 -0
  57. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/test_start_cli.py +0 -0
  58. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee/workflow_graph.py +0 -0
  59. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_admin/__init__.py +0 -0
  60. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_admin/codee_admin.py +0 -0
  61. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_abstract/__init__.py +0 -0
  62. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_abstract/provider.py +0 -0
  63. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_claude_code/__init__.py +0 -0
  64. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_claude_code/account.py +0 -0
  65. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_claude_code/credentials.py +0 -0
  66. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_claude_code/oauth.py +0 -0
  67. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_claude_code/provider.py +0 -0
  68. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_claude_code/test.py +0 -0
  69. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_claude_code/usage.py +0 -0
  70. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_codex/__init__.py +0 -0
  71. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_codex/provider.py +0 -0
  72. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_codex/test.py +0 -0
  73. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_github_copilot/__init__.py +0 -0
  74. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_github_copilot/provider.py +0 -0
  75. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_github_copilot/test.py +0 -0
  76. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_opencode/__init__.py +0 -0
  77. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_opencode/provider.py +0 -0
  78. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_agent_opencode/test.py +0 -0
  79. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_database/__init__.py +0 -0
  80. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_database/claude_code_accounts.py +0 -0
  81. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_database/database.py +0 -0
  82. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_database/oauth_tokens.py +0 -0
  83. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_main_context/__init__.py +0 -0
  84. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_main_context/context.py +0 -0
  85. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_main_context/logging.py +0 -0
  86. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_main_context/test_context.py +0 -0
  87. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_main_context/test_logging.py +0 -0
  88. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_tasks_abstract/__init__.py +0 -0
  89. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_tasks_abstract/provider.py +0 -0
  90. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_tasks_azure_devops/__init__.py +0 -0
  91. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_tasks_azure_devops/oauth.py +0 -0
  92. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_tasks_azure_devops/provider.py +0 -0
  93. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_tasks_azure_devops/test.py +0 -0
  94. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_tasks_jira/__init__.py +0 -0
  95. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_tasks_jira/provider.py +0 -0
  96. {codee_agent-0.7.0 → codee_agent-0.7.2}/src/codee_tasks_jira/test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codee-agent
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents.
5
5
  Keywords: ai,agent,jira,azure-devops,claude-code,github-copilot,codex,opencode,automation
6
6
  Author: Denis Kibalko
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codee-agent"
3
- version = "0.7.0"
3
+ version = "0.7.2"
4
4
  description = "A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -272,6 +272,7 @@ class RunRecord(BaseModel):
272
272
  started_at: str
273
273
  ended_at: str
274
274
  duration_label: str
275
+ relative_age_label: str
275
276
  session_id: str
276
277
  message: str
277
278
  user_message: str
@@ -318,6 +319,7 @@ class AdminState(rx.State):
318
319
  adding_repository: bool = False
319
320
 
320
321
  active_jobs: list[ActiveJob] = []
322
+ paused: bool = False
321
323
  total_runs: int = 0
322
324
  last_24h_runs: int = 0
323
325
  hourly_runs: list[dict[str, Any]] = []
@@ -778,10 +780,14 @@ class AdminState(rx.State):
778
780
  def _refresh_dashboard(self) -> None:
779
781
  dashboard = SERVICE.dashboard()
780
782
  self.active_jobs = [_active_job(job) for job in dashboard["active"]]
783
+ self.paused = dashboard["paused"]
781
784
  self.total_runs = dashboard["counts"]["total"]
782
785
  self.last_24h_runs = dashboard["counts"]["last_24h"]
783
786
  self.hourly_runs = dashboard["hourly"]
784
787
 
788
+ def toggle_pause(self) -> None:
789
+ self.paused = SERVICE.set_paused(not self.paused)
790
+
785
791
  @rx.event(background=True)
786
792
  async def poll_dashboard(self) -> None:
787
793
  """Keep the dashboard live for as long as it is the page on screen.
@@ -863,6 +869,7 @@ class AdminState(rx.State):
863
869
  started_at=run["started_at"],
864
870
  ended_at=run["ended_at"],
865
871
  duration_label=run["duration_label"],
872
+ relative_age_label=run["relative_age_label"],
866
873
  session_id=run.get("session_id") or "",
867
874
  message=message,
868
875
  user_message=(run.get("user_message") or message).strip(),
@@ -1976,6 +1983,16 @@ def running_panel() -> rx.Component:
1976
1983
  font_family=MONO, class_name="codee-breathe",
1977
1984
  animation="codee-breathe 2.4s ease-in-out infinite"),
1978
1985
  ),
1986
+ rx.spacer(),
1987
+ rx.button(
1988
+ rx.cond(AdminState.paused,
1989
+ rx.icon("play", size=15),
1990
+ rx.icon("pause", size=15)),
1991
+ rx.cond(AdminState.paused, "Unpause", "Pause"),
1992
+ variant="outline",
1993
+ color_scheme=rx.cond(AdminState.paused, "green", "gray"),
1994
+ on_click=AdminState.toggle_pause,
1995
+ ),
1979
1996
  spacing="3",
1980
1997
  align="center",
1981
1998
  width="100%",
@@ -2572,9 +2589,12 @@ def run_row(run: RunRecord) -> rx.Component:
2572
2589
  rx.flex(
2573
2590
  rx.vstack(rx.hstack(rx.text(run.skill_name, font_weight="600"),
2574
2591
  rx.badge(run.status, color_scheme=rx.cond(run.status == "succeeded", "green", "red"))),
2575
- rx.text(local_datetime(run.ended_at), color=MUTED,
2576
- font_size="0.8rem",
2577
- font_family="IBM Plex Mono, monospace"),
2592
+ rx.hstack(
2593
+ rx.text(local_datetime(run.ended_at)),
2594
+ rx.text("(", run.relative_age_label, ")"),
2595
+ color=MUTED, font_size="0.8rem",
2596
+ font_family="IBM Plex Mono, monospace",
2597
+ spacing="2"),
2578
2598
  rx.text("Duration: ", run.duration_label, color=MUTED,
2579
2599
  font_size="0.8rem",
2580
2600
  font_family="IBM Plex Mono, monospace"),
@@ -35,6 +35,7 @@ from codee.lib import runs_db
35
35
  from codee.lib.claude_key_rotation import ensure_fresh
36
36
  from codee.lib.cron_describe import describe_cron
37
37
  from codee.lib.mcp_config import find_mcp_server, write_mcp_server
38
+ from codee.lib.runtime_control import is_paused, set_paused
38
39
  from codee.lib.trigger_cron_skills import request_force_run
39
40
  from codee.lib.trigger_issue_skills import (
40
41
  IssueTriggeredSkill,
@@ -59,6 +60,26 @@ from codee_main_context.context import (
59
60
  work_item_mappings,
60
61
  )
61
62
 
63
+
64
+ def relative_age_label(value: str, now: datetime | None = None) -> str:
65
+ """Format an ISO timestamp as a compact minute, hour, or day age."""
66
+ try:
67
+ timestamp = datetime.fromisoformat(value)
68
+ except (TypeError, ValueError):
69
+ return ""
70
+ if timestamp.tzinfo is None:
71
+ timestamp = timestamp.replace(tzinfo=timezone.utc)
72
+ current = now or datetime.now(timezone.utc)
73
+ elapsed = max(int((current - timestamp).total_seconds()), 0)
74
+ if elapsed < 3600:
75
+ amount, unit = max(elapsed // 60, 1), "minute"
76
+ elif elapsed < 86400:
77
+ amount, unit = elapsed // 3600, "hour"
78
+ else:
79
+ amount, unit = elapsed // 86400, "day"
80
+ return f"{amount} {unit}{'' if amount == 1 else 's'} ago"
81
+
82
+
62
83
  load_dotenv()
63
84
 
64
85
  MANAGED = {
@@ -1300,16 +1321,25 @@ class AdminService:
1300
1321
  f"Generating workflow for work item {issue_type.capitalize()} "
1301
1322
  f"from {_count(len(skills), 'issue-trigger skill')}..."
1302
1323
  )
1324
+ session_id = str(uuid.uuid4())
1325
+
1326
+ def opened(agent_session_id: str) -> None:
1327
+ nonlocal session_id
1328
+ session_id = agent_session_id
1329
+
1303
1330
  validation_error = ""
1304
1331
  for attempt in range(2):
1305
- request = prompt
1306
1332
  if validation_error:
1307
- request += (
1308
- "\n\nYour previous response was invalid: "
1309
- f"{validation_error}. Return corrected JSON only."
1333
+ response = agent.continue_conversation(
1334
+ "Your previous response was invalid: "
1335
+ f"{validation_error}. Return corrected JSON only.",
1336
+ session_id,
1337
+ agent.best_model(),
1338
+ opened,
1310
1339
  )
1311
- response = agent.run(
1312
- request, str(uuid.uuid4()), agent.best_model())
1340
+ else:
1341
+ response = agent.run(
1342
+ prompt, session_id, agent.best_model(), opened)
1313
1343
  payload_text = _strip_code_fence(response)
1314
1344
  try:
1315
1345
  payload = json.loads(payload_text)
@@ -2020,8 +2050,14 @@ class AdminService:
2020
2050
  "active": active,
2021
2051
  "counts": runs_db.counts(main_context=self.context),
2022
2052
  "hourly": runs_db.runs_by_hour(main_context=self.context),
2053
+ "paused": is_paused(self.context),
2023
2054
  }
2024
2055
 
2056
+ def set_paused(self, paused: bool) -> bool:
2057
+ """Allow or prevent future automated agent runs."""
2058
+ set_paused(self.context, paused)
2059
+ return is_paused(self.context)
2060
+
2025
2061
  def _task_url(self) -> Callable[[str], str]:
2026
2062
  """How to link a work item key, per the configured tasks provider.
2027
2063
 
@@ -2050,6 +2086,7 @@ class AdminService:
2050
2086
  search: str = "") -> list[dict[str, Any]]:
2051
2087
  rows = runs_db.recent_runs(
2052
2088
  limit, offset, search=search, main_context=self.context)
2089
+ now = datetime.now(timezone.utc)
2053
2090
  for row in rows:
2054
2091
  try:
2055
2092
  elapsed = (datetime.fromisoformat(row["ended_at"])
@@ -2057,6 +2094,8 @@ class AdminService:
2057
2094
  except (TypeError, ValueError):
2058
2095
  elapsed = 0
2059
2096
  row["duration_label"] = runs_db.fmt_elapsed(max(int(elapsed), 0))
2097
+ row["relative_age_label"] = relative_age_label(
2098
+ row["ended_at"], now)
2060
2099
  return rows
2061
2100
 
2062
2101
  def load_settings(self) -> Settings:
@@ -21,6 +21,7 @@ from codee_tasks_abstract.provider import AbstractTasksProvider
21
21
 
22
22
  from codee.coding_agents import resolve_agent_code
23
23
  from codee.lib import claude_key_rotation, runs_db
24
+ from codee.lib.runtime_control import is_paused
24
25
  from codee.lib.trigger_aws_sqs_skills import trigger_aws_sqs_skills
25
26
  from codee.lib.trigger_cron_skills import trigger_cron_skills
26
27
  from codee.lib.trigger_email_skills import trigger_email_skills
@@ -359,6 +360,9 @@ def _submit_task(task_id: str, message: str, session_id: str, skill_name: str,
359
360
  against the next tick.
360
361
  """
361
362
  with _inflight_lock:
363
+ if is_paused(context):
364
+ log.debug("New agent work was paused before %s could start.", task_id)
365
+ return False
362
366
  if task_id in _inflight:
363
367
  log.debug("%s already running; skipping duplicate launch.", task_id)
364
368
  return False
@@ -387,6 +391,10 @@ def run_once() -> None:
387
391
  if not _pull_latest_code():
388
392
  log.warning("Failed to pull from the repo, still continuing...")
389
393
 
394
+ if is_paused(context):
395
+ log.info("New agent work is paused; active agents continue running.")
396
+ return
397
+
390
398
  trigger_cron_skills(_run_agent, main_context=context)
391
399
  trigger_aws_sqs_skills(_run_agent, main_context=context)
392
400
  trigger_email_skills(_run_agent, main_context=context)
@@ -0,0 +1,21 @@
1
+ """Persistent controls shared by the admin UI and executor process."""
2
+ from pathlib import Path
3
+
4
+ from codee_main_context.context import CodeeMainContext
5
+
6
+ PAUSED_FILE = "paused"
7
+
8
+
9
+ def is_paused(context: CodeeMainContext) -> bool:
10
+ """Return whether new agent work is paused."""
11
+ return (context.data_dir / PAUSED_FILE).exists()
12
+
13
+
14
+ def set_paused(context: CodeeMainContext, paused: bool) -> None:
15
+ """Persist whether new agent work may start."""
16
+ path = context.data_dir / PAUSED_FILE
17
+ if paused:
18
+ path.parent.mkdir(parents=True, exist_ok=True)
19
+ path.touch(exist_ok=True)
20
+ else:
21
+ path.unlink(missing_ok=True)
@@ -5,6 +5,7 @@ import tempfile
5
5
  import threading
6
6
  import time
7
7
  import unittest
8
+ from datetime import datetime, timezone
8
9
  from pathlib import Path
9
10
  from unittest.mock import Mock, patch
10
11
  from urllib.parse import parse_qs, urlparse
@@ -19,7 +20,7 @@ from codee.admin_service import (
19
20
  USAGE_RATE_LIMIT_BACKOFF_SECONDS, WORKFLOW_CACHE_VERSION,
20
21
  WORKFLOW_HUMAN_EDGE_COLOR, AdminService, WorkflowGeneration,
21
22
  _remove_redundant_skill_transitions, azure_oauth, issue_prompt_task,
22
- normalize_work_items, parse_skill, repository_name)
23
+ normalize_work_items, parse_skill, relative_age_label, repository_name)
23
24
  from codee.lib import runs_db
24
25
  from codee_agent_claude_code import oauth as claude_oauth
25
26
  from codee_agent_claude_code.account import AccountUnavailable
@@ -61,6 +62,30 @@ def _write_issue_skill(root: Path) -> Path:
61
62
 
62
63
 
63
64
  class RecentRunsTest(unittest.TestCase):
65
+ def test_dashboard_pause_state_persists(self) -> None:
66
+ with tempfile.TemporaryDirectory() as directory:
67
+ service = AdminService.__new__(AdminService)
68
+ service.data_dir = Path(directory)
69
+ service.context = CodeeMainContext(data_dir=service.data_dir)
70
+
71
+ self.assertFalse(service.set_paused(False))
72
+ self.assertTrue(service.set_paused(True))
73
+ self.assertTrue(service.dashboard()["paused"])
74
+ self.assertFalse(service.set_paused(False))
75
+
76
+ def test_formats_relative_age_in_minutes_hours_and_days(self) -> None:
77
+ now = datetime(2026, 9, 24, 12, 0, tzinfo=timezone.utc)
78
+ cases = [
79
+ ("2026-09-24T11:59:30+00:00", "1 minute ago"),
80
+ ("2026-09-24T11:58:00+00:00", "2 minutes ago"),
81
+ ("2026-09-24T10:00:00+00:00", "2 hours ago"),
82
+ ("2026-09-21T12:00:00+00:00", "3 days ago"),
83
+ ]
84
+
85
+ for timestamp, expected in cases:
86
+ with self.subTest(timestamp=timestamp):
87
+ self.assertEqual(relative_age_label(timestamp, now), expected)
88
+
64
89
  def test_formats_duration_from_start_and_end_times(self) -> None:
65
90
  with tempfile.TemporaryDirectory() as directory:
66
91
  context = CodeeMainContext(data_dir=Path(directory))
@@ -1281,18 +1306,25 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
1281
1306
  "Move the story to CR Needed when every subtask is implemented.\n"
1282
1307
  )
1283
1308
  agent = Mock()
1284
- agent.run.side_effect = [
1285
- '{"statuses":["Ready","Subtask Review","CR Needed"],'
1286
- '"transitions":[{"source":"Ready","target":"Subtask Review",'
1287
- '"label":"story-developer","evidence":"Move each subtask to '
1288
- 'Subtask Review once its pull request is open."}],'
1289
- '"final_statuses":["CR Needed"]}',
1309
+
1310
+ def invalid_workflow(message, session_id, model, on_session_id):
1311
+ on_session_id("agent-thread")
1312
+ return (
1313
+ '{"statuses":["Ready","Subtask Review","CR Needed"],'
1314
+ '"transitions":[{"source":"Ready","target":"Subtask Review",'
1315
+ '"label":"story-developer","evidence":"Move each subtask to '
1316
+ 'Subtask Review once its pull request is open."}],'
1317
+ '"final_statuses":["CR Needed"]}'
1318
+ )
1319
+
1320
+ agent.run.side_effect = invalid_workflow
1321
+ agent.continue_conversation.return_value = (
1290
1322
  '{"statuses":["Ready","CR Needed"],'
1291
1323
  '"transitions":[{"source":"Ready","target":"CR Needed",'
1292
1324
  '"label":"story-developer","evidence":"Move the story to CR '
1293
1325
  'Needed when every subtask is implemented."}],'
1294
- '"final_statuses":["CR Needed"]}',
1295
- ]
1326
+ '"final_statuses":["CR Needed"]}'
1327
+ )
1296
1328
  service = AdminService.__new__(AdminService)
1297
1329
  service.root = root
1298
1330
  service.skills_dir = skills_dir
@@ -1305,14 +1337,23 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
1305
1337
  }):
1306
1338
  workflow = service.generate_workflow()["story"]
1307
1339
 
1308
- self.assertEqual(agent.run.call_count, 2)
1340
+ self.assertEqual(agent.run.call_count, 1)
1341
+ self.assertEqual(agent.continue_conversation.call_count, 1)
1309
1342
  self.assertIn(
1310
1343
  "moves the subtask rather than the story",
1311
- agent.run.call_args.args[0],
1344
+ agent.continue_conversation.call_args.args[0],
1312
1345
  )
1313
1346
  self.assertIn(
1314
1347
  "The graph is the lifecycle of the story work item alone",
1315
- agent.run.call_args_list[0].args[0],
1348
+ agent.run.call_args.args[0],
1349
+ )
1350
+ self.assertNotIn(
1351
+ "The graph is the lifecycle of the story work item alone",
1352
+ agent.continue_conversation.call_args.args[0],
1353
+ )
1354
+ self.assertEqual(
1355
+ agent.continue_conversation.call_args.args[1],
1356
+ "agent-thread",
1316
1357
  )
1317
1358
  self.assertEqual(
1318
1359
  [node["data"]["label"] for node in workflow["nodes"]],
@@ -1382,12 +1423,14 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
1382
1423
  "After planning, move the story to [AI] Ready for human review.\n"
1383
1424
  )
1384
1425
  agent = Mock()
1385
- agent.run.side_effect = [
1426
+ agent.run.return_value = (
1386
1427
  '{"statuses":["[AI] Decomposition needed",'
1387
1428
  '"[AI] Ready for development","[AI] Ready for human review"],'
1388
1429
  '"transitions":[{"source":"[AI] Decomposition needed",'
1389
1430
  '"target":"[AI] Ready for development","label":"",'
1390
- '"evidence":""}],"final_statuses":[]}',
1431
+ '"evidence":""}],"final_statuses":[]}'
1432
+ )
1433
+ agent.continue_conversation.return_value = (
1391
1434
  '{"statuses":["[AI] Decomposition needed",'
1392
1435
  '"[AI] Ready for development","[AI] Ready for human review"],'
1393
1436
  '"transitions":[{'
@@ -1395,8 +1438,8 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
1395
1438
  '"target":"[AI] Ready for human review",'
1396
1439
  '"label":"story-planner",'
1397
1440
  '"evidence":"After planning, move the story to [AI] Ready for human review."}],'
1398
- '"final_statuses":["[AI] Ready for human review"]}',
1399
- ]
1441
+ '"final_statuses":["[AI] Ready for human review"]}'
1442
+ )
1400
1443
  service = AdminService.__new__(AdminService)
1401
1444
  service.root = root
1402
1445
  service.skills_dir = skills_dir
@@ -1409,8 +1452,9 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
1409
1452
  }):
1410
1453
  workflow = service.generate_workflow()["story"]
1411
1454
 
1412
- self.assertEqual(agent.run.call_count, 2)
1413
- retry_prompt = agent.run.call_args.args[0]
1455
+ self.assertEqual(agent.run.call_count, 1)
1456
+ self.assertEqual(agent.continue_conversation.call_count, 1)
1457
+ retry_prompt = agent.continue_conversation.call_args.args[0]
1414
1458
  self.assertIn(
1415
1459
  'transition 1 ("[AI] Decomposition needed" -> '
1416
1460
  '"[AI] Ready for development", label "") is labelled with a '
@@ -1501,16 +1545,18 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
1501
1545
  "Move the issue to Review when work is done.\n"
1502
1546
  )
1503
1547
  agent = Mock()
1504
- agent.run.side_effect = [
1548
+ agent.run.return_value = (
1505
1549
  '{"statuses":["Ready","Review"],"transitions":['
1506
1550
  '{"source":"Ready","target":"Review","label":"develop",'
1507
1551
  '"evidence":"Work the issue until it is done."}],'
1508
- '"final_statuses":["Review"]}',
1552
+ '"final_statuses":["Review"]}'
1553
+ )
1554
+ agent.continue_conversation.return_value = (
1509
1555
  '{"statuses":["Ready","Review"],"transitions":['
1510
1556
  '{"source":"Ready","target":"Review","label":"develop",'
1511
1557
  '"evidence":"Move the issue to Review when work is done."}],'
1512
- '"final_statuses":["Review"]}',
1513
- ]
1558
+ '"final_statuses":["Review"]}'
1559
+ )
1514
1560
  service = AdminService.__new__(AdminService)
1515
1561
  service.root = root
1516
1562
  service.skills_dir = skills_dir
@@ -1554,18 +1600,20 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
1554
1600
  "Move the issue to Review when work is done.\n"
1555
1601
  )
1556
1602
  agent = Mock()
1557
- agent.run.side_effect = [
1603
+ agent.run.return_value = (
1558
1604
  '{"statuses":["Ready","Review"],"transitions":['
1559
1605
  '{"source":"Blocked","target":"Review","label":"develop",'
1560
1606
  '"evidence":"Move the issue to Review when work is done."},'
1561
1607
  '{"source":"Ready","target":"Review","label":"develop",'
1562
1608
  '"evidence":"Work the issue until it is done."}],'
1563
- '"final_statuses":["Done"]}',
1609
+ '"final_statuses":["Done"]}'
1610
+ )
1611
+ agent.continue_conversation.return_value = (
1564
1612
  '{"statuses":["Ready","Review"],"transitions":['
1565
1613
  '{"source":"Ready","target":"Review","label":"develop",'
1566
1614
  '"evidence":"Move the issue to Review when work is done."}],'
1567
- '"final_statuses":["Review"]}',
1568
- ]
1615
+ '"final_statuses":["Review"]}'
1616
+ )
1569
1617
  service = AdminService.__new__(AdminService)
1570
1618
  service.root = root
1571
1619
  service.skills_dir = skills_dir
@@ -1578,7 +1626,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
1578
1626
  }):
1579
1627
  service.generate_workflow()
1580
1628
 
1581
- retry_prompt = agent.run.call_args.args[0]
1629
+ retry_prompt = agent.continue_conversation.call_args.args[0]
1582
1630
  self.assertIn('transition 1 ("Blocked" -> "Review", label '
1583
1631
  '"develop") uses "Blocked", which is missing from '
1584
1632
  'statuses "Ready", "Review"', retry_prompt)
@@ -1601,16 +1649,18 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
1601
1649
  "Move the issue to Done after approval.\n"
1602
1650
  )
1603
1651
  agent = Mock()
1604
- agent.run.side_effect = [
1652
+ agent.run.return_value = (
1605
1653
  '{"statuses":["Ready","Review","Done"],"transitions":['
1606
1654
  '{"source":"Ready","target":"Done","label":"review",'
1607
1655
  '"evidence":"Move the issue to Done after approval."}],'
1608
- '"final_statuses":["Done"]}',
1656
+ '"final_statuses":["Done"]}'
1657
+ )
1658
+ agent.continue_conversation.return_value = (
1609
1659
  '{"statuses":["Ready","Review","Done"],"transitions":['
1610
1660
  '{"source":"Review","target":"Done","label":"review",'
1611
1661
  '"evidence":"Move the issue to Done after approval."}],'
1612
- '"final_statuses":["Done"]}',
1613
- ]
1662
+ '"final_statuses":["Done"]}'
1663
+ )
1614
1664
  service = AdminService.__new__(AdminService)
1615
1665
  service.root = root
1616
1666
  service.skills_dir = skills_dir
@@ -1628,7 +1678,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
1628
1678
  'a status "review" never has the issue in: its entry statuses '
1629
1679
  'are "Review" and no other transition of that skill moves the '
1630
1680
  'issue to "Ready".',
1631
- agent.run.call_args.args[0],
1681
+ agent.continue_conversation.call_args.args[0],
1632
1682
  )
1633
1683
 
1634
1684
  def test_generate_workflow_warns_when_statuses_have_no_edges(self) -> None:
@@ -1840,15 +1890,17 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
1840
1890
  "Move the issue to In Progress when work starts.\n"
1841
1891
  )
1842
1892
  agent = Mock()
1843
- agent.run.side_effect = [
1893
+ agent.run.return_value = (
1844
1894
  '{"statuses":["Ready","In Progress"],"transitions":[],'
1845
1895
  '"final_statuses":[],"human_actions":[],'
1846
1896
  '"human_transitions":[{"source":"Ready","target":"In Progress",'
1847
- '"evidence":"Move the issue to In Progress when work starts."}]}',
1897
+ '"evidence":"Move the issue to In Progress when work starts."}]}'
1898
+ )
1899
+ agent.continue_conversation.return_value = (
1848
1900
  '{"statuses":["Ready","In Progress"],"transitions":[],'
1849
1901
  '"final_statuses":[],"human_actions":[],'
1850
- '"human_transitions":[]}',
1851
- ]
1902
+ '"human_transitions":[]}'
1903
+ )
1852
1904
  service = AdminService.__new__(AdminService)
1853
1905
  service.root = root
1854
1906
  service.skills_dir = skills_dir
@@ -1861,10 +1913,11 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
1861
1913
  }):
1862
1914
  service.generate_workflow()
1863
1915
 
1864
- self.assertEqual(agent.run.call_count, 2)
1916
+ self.assertEqual(agent.run.call_count, 1)
1917
+ self.assertEqual(agent.continue_conversation.call_count, 1)
1865
1918
  self.assertIn(
1866
1919
  "human transition source Ready is an entry status of a skill",
1867
- agent.run.call_args.args[0],
1920
+ agent.continue_conversation.call_args.args[0],
1868
1921
  )
1869
1922
 
1870
1923
  def test_generate_workflow_rejects_a_human_action_for_an_unknown_status(
@@ -1882,13 +1935,15 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
1882
1935
  "Move the issue to In Progress when work starts.\n"
1883
1936
  )
1884
1937
  agent = Mock()
1885
- agent.run.side_effect = [
1938
+ agent.run.return_value = (
1886
1939
  '{"statuses":["Ready","In Progress"],"transitions":[],'
1887
1940
  '"final_statuses":[],"human_actions":[{"status":"Blocked",'
1888
- '"action":"Unblock the story."}]}',
1941
+ '"action":"Unblock the story."}]}'
1942
+ )
1943
+ agent.continue_conversation.return_value = (
1889
1944
  '{"statuses":["Ready","In Progress"],"transitions":[],'
1890
- '"final_statuses":[],"human_actions":[]}',
1891
- ]
1945
+ '"final_statuses":[],"human_actions":[]}'
1946
+ )
1892
1947
  service = AdminService.__new__(AdminService)
1893
1948
  service.root = root
1894
1949
  service.skills_dir = skills_dir
@@ -1901,10 +1956,11 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
1901
1956
  }):
1902
1957
  service.generate_workflow()
1903
1958
 
1904
- self.assertEqual(agent.run.call_count, 2)
1959
+ self.assertEqual(agent.run.call_count, 1)
1960
+ self.assertEqual(agent.continue_conversation.call_count, 1)
1905
1961
  self.assertIn(
1906
1962
  "each human action must name a declared status",
1907
- agent.run.call_args.args[0],
1963
+ agent.continue_conversation.call_args.args[0],
1908
1964
  )
1909
1965
 
1910
1966
  def test_generate_workflow_returns_empty_without_issue_skills(self) -> None:
@@ -533,6 +533,27 @@ class ParentWorkItemSkipTest(unittest.TestCase):
533
533
  executor.run_once()
534
534
  return [call.args[0] for call in submit.call_args_list]
535
535
 
536
+ def test_paused_executor_starts_no_trigger_or_issue_work(self) -> None:
537
+ with patch.object(executor, "is_paused", return_value=True), \
538
+ patch.object(executor, "trigger_cron_skills") as cron, \
539
+ patch.object(executor, "trigger_aws_sqs_skills") as sqs, \
540
+ patch.object(executor, "trigger_email_skills") as email:
541
+ executor.run_once()
542
+
543
+ cron.assert_not_called()
544
+ sqs.assert_not_called()
545
+ email.assert_not_called()
546
+ self.provider.get_tasks.assert_not_called()
547
+
548
+ def test_pause_while_polling_prevents_issue_launch(self) -> None:
549
+ with patch.object(executor, "is_paused", return_value=True), \
550
+ patch.object(executor, "_run_task") as run_task:
551
+ launched = executor._submit_task(
552
+ "NIM-4", "prompt", "session", "Task developer")
553
+
554
+ self.assertFalse(launched)
555
+ run_task.assert_not_called()
556
+
536
557
  def test_an_item_under_a_mapped_parent_is_left_to_that_parents_run(self) -> None:
537
558
  self.assertEqual(
538
559
  self._submitted(_polled_task("NIM-1", parent_type="Story")), [])
File without changes
File without changes