devs-webhook 0.1.2__tar.gz → 0.1.4__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 (49) hide show
  1. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/PKG-INFO +1 -1
  2. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/config.py +13 -0
  3. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/claude_dispatcher.py +55 -17
  4. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/container_pool.py +49 -0
  5. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/test_dispatcher.py +53 -14
  6. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/main_cli.py +49 -3
  7. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/sources/sqs_source.py +56 -4
  8. devs_webhook-0.1.4/devs_webhook/utils/container_logs.py +256 -0
  9. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook.egg-info/PKG-INFO +1 -1
  10. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook.egg-info/SOURCES.txt +2 -0
  11. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/pyproject.toml +2 -2
  12. devs_webhook-0.1.4/tests/test_container_logs.py +247 -0
  13. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/tests/test_sqs_burst.py +137 -2
  14. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/LICENSE +0 -0
  15. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/README.md +0 -0
  16. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/__init__.py +0 -0
  17. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/app.py +0 -0
  18. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/cli/__init__.py +0 -0
  19. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/cli/worker.py +0 -0
  20. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/__init__.py +0 -0
  21. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/base_dispatcher.py +0 -0
  22. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/deduplication.py +0 -0
  23. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/repository_manager.py +0 -0
  24. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/task_processor.py +0 -0
  25. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/webhook_config.py +0 -0
  26. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/webhook_handler.py +0 -0
  27. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/github/__init__.py +0 -0
  28. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/github/app_auth.py +0 -0
  29. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/github/client.py +0 -0
  30. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/github/models.py +0 -0
  31. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/github/parser.py +0 -0
  32. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/sources/__init__.py +0 -0
  33. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/sources/base.py +0 -0
  34. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/sources/webhook_source.py +0 -0
  35. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/utils/__init__.py +0 -0
  36. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/utils/async_utils.py +0 -0
  37. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/utils/github.py +0 -0
  38. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/utils/logging.py +0 -0
  39. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/utils/serialization.py +0 -0
  40. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook.egg-info/dependency_links.txt +0 -0
  41. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook.egg-info/entry_points.txt +0 -0
  42. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook.egg-info/requires.txt +0 -0
  43. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook.egg-info/top_level.txt +0 -0
  44. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/setup.cfg +0 -0
  45. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/tests/test_allowlist.py +0 -0
  46. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/tests/test_authentication.py +0 -0
  47. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/tests/test_authorized_users.py +0 -0
  48. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/tests/test_single_queue.py +0 -0
  49. {devs_webhook-0.1.2 → devs_webhook-0.1.4}/tests/test_webhook_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-webhook
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: GitHub webhook handler for automated devcontainer operations with Claude Code
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -89,6 +89,16 @@ class WebhookConfig(BaseSettings, BaseConfig):
89
89
  log_level: str = Field(default="INFO", description="Logging level")
90
90
  log_format: str = Field(default="json", description="Logging format (json|console)")
91
91
 
92
+ # Container output logging (CloudWatch-friendly)
93
+ container_logs_dir: Path = Field(
94
+ default_factory=lambda: Path("/var/log/devs-webhook/containers"),
95
+ description="Directory for container output logs (CloudWatch agent compatible)"
96
+ )
97
+ container_logs_enabled: bool = Field(
98
+ default=False,
99
+ description="Enable writing container output to log files"
100
+ )
101
+
92
102
  # Task source configuration
93
103
  task_source: str = Field(
94
104
  default="webhook",
@@ -166,6 +176,9 @@ class WebhookConfig(BaseSettings, BaseConfig):
166
176
  self.repo_cache_dir.mkdir(parents=True, exist_ok=True)
167
177
  # Claude config directory for container mounts
168
178
  self.claude_config_dir.mkdir(parents=True, exist_ok=True)
179
+ # Container logs directory (if enabled)
180
+ if self.container_logs_enabled:
181
+ self.container_logs_dir.mkdir(parents=True, exist_ok=True)
169
182
 
170
183
  def validate_required_settings(self) -> None:
171
184
  """Validate that required settings are present."""
@@ -1,5 +1,6 @@
1
1
  """Claude Code CLI integration for executing tasks in containers."""
2
2
 
3
+ import uuid
3
4
  from typing import Optional
4
5
  import structlog
5
6
  from pathlib import Path
@@ -10,6 +11,7 @@ from devs_common.core.workspace import WorkspaceManager
10
11
  from ..github.models import WebhookEvent, IssueEvent, PullRequestEvent, CommentEvent
11
12
  from devs_common.devs_config import DevsOptions
12
13
  from .base_dispatcher import BaseDispatcher, TaskResult
14
+ from ..utils.container_logs import create_container_log_writer
13
15
 
14
16
  logger = structlog.get_logger()
15
17
 
@@ -29,36 +31,44 @@ class ClaudeDispatcher(BaseDispatcher):
29
31
  repo_path: Path,
30
32
  event: WebhookEvent,
31
33
  devs_options: Optional[DevsOptions] = None,
32
- task_description: Optional[str] = None
34
+ task_description: Optional[str] = None,
35
+ task_id: Optional[str] = None
33
36
  ) -> TaskResult:
34
37
  """Execute a task using Claude Code CLI in a container.
35
-
38
+
36
39
  Args:
37
40
  dev_name: Name of dev container (e.g., eamonn)
38
41
  repo_path: Path to repository on host (already calculated by container_pool)
39
42
  task_description: Task description for Claude
40
43
  event: Original webhook event
41
44
  devs_options: Options from DEVS.yml file
42
-
45
+ task_id: Optional task identifier for logging
46
+
43
47
  Returns:
44
48
  Task execution result
45
49
  """
50
+ # Generate task_id if not provided
51
+ if not task_id:
52
+ task_id = str(uuid.uuid4())[:8]
53
+
46
54
  try:
47
55
  logger.info("Starting Claude Code CLI task",
48
56
  container=dev_name,
49
57
  repo=event.repository.full_name,
50
- repo_path=str(repo_path))
51
-
58
+ repo_path=str(repo_path),
59
+ task_id=task_id)
60
+
52
61
  # Execute Claude directly - prompt building, workspace setup, container startup, Claude execution
53
62
  # Use task_description if provided, otherwise extract from event
54
63
  task_desc = task_description or "Process webhook event"
55
-
64
+
56
65
  success, output, error = self._execute_claude_sync(
57
66
  repo_path,
58
67
  dev_name,
59
68
  task_desc,
60
69
  event,
61
- devs_options
70
+ devs_options,
71
+ task_id=task_id
62
72
  )
63
73
 
64
74
  # Build result - ensure we have meaningful error messages
@@ -107,26 +117,37 @@ class ClaudeDispatcher(BaseDispatcher):
107
117
  dev_name: str,
108
118
  task_description: str,
109
119
  event: WebhookEvent,
110
- devs_options: Optional[DevsOptions] = None
120
+ devs_options: Optional[DevsOptions] = None,
121
+ task_id: Optional[str] = None
111
122
  ) -> tuple[bool, str, str]:
112
123
  """Execute complete Claude workflow synchronously.
113
-
124
+
114
125
  This mirrors the CLI approach exactly:
115
126
  1. Create project, workspace manager, and container manager
116
127
  2. Create/reset workspace (force=True for webhook)
117
128
  3. Build prompt
118
129
  4. Execute Claude (which handles container startup)
119
-
130
+
120
131
  Args:
121
132
  repo_path: Path to repository
122
133
  dev_name: Development environment name
123
134
  task_description: Task description for Claude
124
135
  event: Webhook event
125
136
  devs_options: Options from DEVS.yml
126
-
137
+ task_id: Optional task identifier for logging
138
+
127
139
  Returns:
128
140
  Tuple of (success, stdout, stderr)
129
141
  """
142
+ # Create container log writer if enabled
143
+ container_log = create_container_log_writer(
144
+ config=self.config,
145
+ container_name=dev_name,
146
+ task_id=task_id or str(uuid.uuid4())[:8],
147
+ repo_name=event.repository.full_name,
148
+ task_type="claude"
149
+ )
150
+
130
151
  try:
131
152
  # 1. Create project, workspace manager, and container manager like CLI
132
153
  project = Project(repo_path)
@@ -240,11 +261,15 @@ Always remember to PUSH your work to origin!
240
261
  # 4. Execute Claude (like CLI pattern) with environment variables from DEVS.yml
241
262
  logger.info("Executing Claude via ContainerManager (like CLI)",
242
263
  container=dev_name)
243
-
264
+
244
265
  extra_env = None
245
266
  if devs_options:
246
267
  extra_env = devs_options.get_env_vars(dev_name)
247
-
268
+
269
+ # Start container logging if enabled
270
+ if container_log:
271
+ container_log.start(prompt=prompt, workspace_dir=str(workspace_dir))
272
+
248
273
  success, stdout, stderr = container_manager.exec_claude(
249
274
  dev_name=dev_name,
250
275
  workspace_dir=workspace_dir,
@@ -253,7 +278,15 @@ Always remember to PUSH your work to origin!
253
278
  stream=False, # Don't stream in webhook mode
254
279
  extra_env=extra_env
255
280
  )
256
-
281
+
282
+ # Write container output to log file if enabled
283
+ if container_log:
284
+ container_log.write_output(stdout, stderr)
285
+ container_log.end(
286
+ success=success,
287
+ error=stderr if not success else None
288
+ )
289
+
257
290
  # Log the actual output for debugging
258
291
  if not success:
259
292
  logger.error("Claude execution failed",
@@ -261,20 +294,25 @@ Always remember to PUSH your work to origin!
261
294
  stdout=stdout[:1000] if stdout else "",
262
295
  stderr=stderr[:1000] if stderr else "",
263
296
  success=success)
264
-
297
+
265
298
  # If failed and no stderr, check stdout for error messages
266
299
  # (Claude sometimes outputs errors to stdout)
267
300
  if not success and not stderr:
268
301
  stderr = stdout
269
-
302
+
270
303
  return success, stdout, stderr
271
-
304
+
272
305
  except Exception as e:
273
306
  error_msg = f"Claude execution failed: {str(e)}"
274
307
  logger.error("Claude execution error",
275
308
  container=dev_name,
276
309
  error=error_msg,
277
310
  exc_info=True)
311
+
312
+ # Log the error to container log if enabled
313
+ if container_log:
314
+ container_log.end(success=False, error=error_msg)
315
+
278
316
  return False, "", error_msg
279
317
 
280
318
 
@@ -929,6 +929,55 @@ Please check the webhook handler logs for more details, or try mentioning me aga
929
929
  "cached_repo_configs": list(self.repo_configs.keys()),
930
930
  }
931
931
 
932
+ def get_total_queued_tasks(self) -> int:
933
+ """Get the total number of tasks queued across all containers.
934
+
935
+ Returns:
936
+ Total number of tasks waiting in all queues
937
+ """
938
+ return sum(queue.qsize() for queue in self.container_queues.values())
939
+
940
+ async def wait_for_all_tasks_complete(self, timeout: Optional[float] = None) -> bool:
941
+ """Wait for all queued tasks to be processed.
942
+
943
+ This waits for all container queues to be fully drained, meaning
944
+ all tasks have been picked up by workers AND task_done() has been
945
+ called for each (i.e., processing is complete, not just started).
946
+
947
+ Args:
948
+ timeout: Optional timeout in seconds. If None, waits indefinitely.
949
+
950
+ Returns:
951
+ True if all tasks completed, False if timeout occurred.
952
+ """
953
+ logger.info("Waiting for all container queues to drain",
954
+ queues={name: q.qsize() for name, q in self.container_queues.items()})
955
+
956
+ async def wait_all_queues():
957
+ # Wait for each queue to be fully processed
958
+ # asyncio.Queue.join() waits until all items have had task_done() called
959
+ wait_tasks = [
960
+ queue.join()
961
+ for queue in self.container_queues.values()
962
+ ]
963
+ await asyncio.gather(*wait_tasks)
964
+
965
+ try:
966
+ if timeout is not None:
967
+ await asyncio.wait_for(wait_all_queues(), timeout=timeout)
968
+ else:
969
+ await wait_all_queues()
970
+
971
+ logger.info("All container queues drained successfully")
972
+ return True
973
+
974
+ except asyncio.TimeoutError:
975
+ remaining = {name: q.qsize() for name, q in self.container_queues.items()}
976
+ logger.warning("Timeout waiting for queues to drain",
977
+ remaining_tasks=remaining,
978
+ timeout_seconds=timeout)
979
+ return False
980
+
932
981
  async def _idle_cleanup_worker(self) -> None:
933
982
  """Periodically clean up idle containers."""
934
983
  while True:
@@ -1,5 +1,6 @@
1
1
  """Test runner dispatcher for executing CI tests in containers."""
2
2
 
3
+ import uuid
3
4
  from typing import Optional
4
5
  import structlog
5
6
  from pathlib import Path
@@ -10,6 +11,7 @@ from devs_common.core.workspace import WorkspaceManager
10
11
  from ..github.models import WebhookEvent, PushEvent, PullRequestEvent
11
12
  from devs_common.devs_config import DevsOptions
12
13
  from .base_dispatcher import BaseDispatcher, TaskResult
14
+ from ..utils.container_logs import create_container_log_writer
13
15
 
14
16
  logger = structlog.get_logger()
15
17
 
@@ -29,27 +31,34 @@ class TestDispatcher(BaseDispatcher):
29
31
  repo_path: Path,
30
32
  event: WebhookEvent,
31
33
  devs_options: Optional[DevsOptions] = None,
32
- task_description: Optional[str] = None
34
+ task_description: Optional[str] = None,
35
+ task_id: Optional[str] = None
33
36
  ) -> TaskResult:
34
37
  """Execute tests using container and report results via GitHub Checks API.
35
-
38
+
36
39
  Args:
37
40
  dev_name: Name of dev container (e.g., eamonn)
38
41
  repo_path: Path to repository on host (already calculated by container_pool)
39
42
  event: Original webhook event
40
43
  devs_options: Options from DEVS.yml file
41
44
  task_description: Task description (ignored by test dispatcher)
42
-
45
+ task_id: Optional task identifier for logging
46
+
43
47
  Returns:
44
48
  Test execution result
45
49
  """
50
+ # Generate task_id if not provided
51
+ if not task_id:
52
+ task_id = str(uuid.uuid4())[:8]
53
+
46
54
  check_run_id = None
47
-
55
+
48
56
  try:
49
57
  logger.info("Starting test execution",
50
58
  container=dev_name,
51
59
  repo=event.repository.full_name,
52
- repo_path=str(repo_path))
60
+ repo_path=str(repo_path),
61
+ task_id=task_id)
53
62
 
54
63
  # Determine the commit SHA to test
55
64
  commit_sha = self._get_commit_sha(event)
@@ -106,7 +115,8 @@ class TestDispatcher(BaseDispatcher):
106
115
  repo_path,
107
116
  dev_name,
108
117
  event,
109
- devs_options
118
+ devs_options,
119
+ task_id=task_id
110
120
  )
111
121
 
112
122
  # Build result
@@ -186,19 +196,30 @@ class TestDispatcher(BaseDispatcher):
186
196
  repo_path: Path,
187
197
  dev_name: str,
188
198
  event: WebhookEvent,
189
- devs_options: Optional[DevsOptions] = None
199
+ devs_options: Optional[DevsOptions] = None,
200
+ task_id: Optional[str] = None
190
201
  ) -> tuple[bool, str, str, int]:
191
202
  """Execute tests synchronously in container.
192
-
203
+
193
204
  Args:
194
205
  repo_path: Path to repository
195
206
  dev_name: Development environment name
196
207
  event: Webhook event
197
208
  devs_options: Options from DEVS.yml
198
-
209
+ task_id: Optional task identifier for logging
210
+
199
211
  Returns:
200
212
  Tuple of (success, stdout, stderr, exit_code)
201
213
  """
214
+ # Create container log writer if enabled
215
+ container_log = create_container_log_writer(
216
+ config=self.config,
217
+ container_name=dev_name,
218
+ task_id=task_id or str(uuid.uuid4())[:8],
219
+ repo_name=event.repository.full_name,
220
+ task_type="tests"
221
+ )
222
+
202
223
  try:
203
224
  # 1. Create project, workspace manager, and container manager
204
225
  project = Project(repo_path)
@@ -256,11 +277,15 @@ class TestDispatcher(BaseDispatcher):
256
277
  test_command = "./runtests.sh" # Default
257
278
  if devs_options and devs_options.ci_test_command:
258
279
  test_command = devs_options.ci_test_command
259
-
280
+
260
281
  logger.info("Executing test command",
261
282
  container=dev_name,
262
283
  test_command=test_command)
263
-
284
+
285
+ # Start container logging if enabled
286
+ if container_log:
287
+ container_log.start(test_command=test_command, workspace_dir=str(workspace_dir))
288
+
264
289
  # 6. Execute tests
265
290
  success, stdout, stderr, exit_code = self._exec_command_in_container(
266
291
  project=project,
@@ -269,22 +294,36 @@ class TestDispatcher(BaseDispatcher):
269
294
  command=test_command,
270
295
  debug=self.config.dev_mode
271
296
  )
272
-
297
+
298
+ # Write container output to log file if enabled
299
+ if container_log:
300
+ container_log.write_output(stdout, stderr)
301
+ container_log.end(
302
+ success=success,
303
+ exit_code=exit_code,
304
+ error=stderr if not success else None
305
+ )
306
+
273
307
  logger.info("Test command completed",
274
308
  container=dev_name,
275
309
  success=success,
276
310
  exit_code=exit_code,
277
311
  output_length=len(stdout) if stdout else 0,
278
312
  error_length=len(stderr) if stderr else 0)
279
-
313
+
280
314
  return success, stdout, stderr, exit_code
281
-
315
+
282
316
  except Exception as e:
283
317
  error_msg = f"Test execution failed: {str(e)}"
284
318
  logger.error("Test execution error",
285
319
  container=dev_name,
286
320
  error=error_msg,
287
321
  exc_info=True)
322
+
323
+ # Log the error to container log if enabled
324
+ if container_log:
325
+ container_log.end(success=False, exit_code=1, error=error_msg)
326
+
288
327
  return False, "", error_msg, 1
289
328
 
290
329
  def _get_commit_sha(self, event: WebhookEvent) -> Optional[str]:
@@ -30,7 +30,11 @@ cli.add_command(worker)
30
30
  @click.option('--dev', is_flag=True, help='Development mode (auto-loads .env, enables reload, console logs)')
31
31
  @click.option('--source', type=click.Choice(['webhook', 'sqs'], case_sensitive=False), help='Task source override')
32
32
  @click.option('--burst', is_flag=True, help='Burst mode: process all available SQS messages then exit (SQS mode only)')
33
- def serve(host: str, port: int, reload: bool, env_file: Path, dev: bool, source: str, burst: bool):
33
+ @click.option('--no-wait', is_flag=True, help='In burst mode, exit immediately after draining SQS queue without waiting for tasks to complete')
34
+ @click.option('--timeout', type=int, default=None, help='Timeout in seconds for waiting on task completion in burst mode (default: wait indefinitely)')
35
+ @click.option('--container-logs', is_flag=True, help='Enable container output logging to files (CloudWatch compatible)')
36
+ @click.option('--container-logs-dir', type=click.Path(path_type=Path), default=None, help='Directory for container log files (default: /var/log/devs-webhook/containers)')
37
+ def serve(host: str, port: int, reload: bool, env_file: Path, dev: bool, source: str, burst: bool, no_wait: bool, timeout: int, container_logs: bool, container_logs_dir: Path):
34
38
  """Start the webhook handler server.
35
39
 
36
40
  The server can run in two modes:
@@ -40,14 +44,21 @@ def serve(host: str, port: int, reload: bool, env_file: Path, dev: bool, source:
40
44
  SQS mode supports --burst flag to process all available messages then exit:
41
45
  - Exit code 0: Processed one or more messages successfully
42
46
  - Exit code 42: Queue was empty (no messages to process)
47
+ - Exit code 43: Timeout waiting for tasks to complete
43
48
  - Other codes: Error occurred
44
49
 
50
+ By default, burst mode waits for all container tasks (Docker jobs) to complete
51
+ before exiting. Use --no-wait to exit immediately after draining the SQS queue,
52
+ or --timeout to set a maximum wait time.
53
+
45
54
  Examples:
46
55
  devs-webhook serve --dev # Development mode with .env loading
47
56
  devs-webhook serve --env-file /path/.env # Load specific .env file
48
57
  devs-webhook serve --host 127.0.0.1 # Override host from config
49
58
  devs-webhook serve --source sqs # Use SQS polling mode
50
- devs-webhook serve --source sqs --burst # Process all SQS messages then exit
59
+ devs-webhook serve --source sqs --burst # Process all SQS messages, wait for completion
60
+ devs-webhook serve --source sqs --burst --no-wait # Drain SQS and exit immediately
61
+ devs-webhook serve --source sqs --burst --timeout 3600 # Wait up to 1 hour for tasks
51
62
  """
52
63
  # Handle development mode
53
64
  if dev:
@@ -87,6 +98,12 @@ def serve(host: str, port: int, reload: bool, env_file: Path, dev: bool, source:
87
98
  if source:
88
99
  os.environ["TASK_SOURCE"] = source
89
100
 
101
+ # Configure container logs if specified via CLI
102
+ if container_logs:
103
+ os.environ["CONTAINER_LOGS_ENABLED"] = "true"
104
+ if container_logs_dir:
105
+ os.environ["CONTAINER_LOGS_DIR"] = str(container_logs_dir)
106
+
90
107
  # Now setup logging after environment is configured
91
108
  setup_logging()
92
109
 
@@ -97,6 +114,8 @@ def serve(host: str, port: int, reload: bool, env_file: Path, dev: bool, source:
97
114
  click.echo(f"Task source: {config.task_source}")
98
115
  click.echo(f"Watching for @{config.github_mentioned_user} mentions")
99
116
  click.echo(f"Container pool: {', '.join(config.get_container_pool_list())}")
117
+ if config.container_logs_enabled:
118
+ click.echo(f"Container logs: {config.container_logs_dir}")
100
119
 
101
120
  # Validate burst mode is only used with SQS
102
121
  if burst and config.task_source != "sqs":
@@ -128,13 +147,24 @@ def serve(host: str, port: int, reload: bool, env_file: Path, dev: bool, source:
128
147
  click.echo(f"DLQ configured: {config.aws_sqs_dlq_url}")
129
148
  if burst:
130
149
  click.echo("Burst mode: will process all messages then exit")
150
+ if no_wait:
151
+ click.echo(" --no-wait: will NOT wait for container tasks to complete")
152
+ else:
153
+ if timeout:
154
+ click.echo(f" Will wait up to {timeout}s for container tasks to complete")
155
+ else:
156
+ click.echo(" Will wait for all container tasks to complete before exit")
131
157
 
132
158
  # Import and run SQS source
133
159
  import asyncio
134
160
  from .sources.sqs_source import SQSTaskSource
135
161
 
136
162
  async def run_sqs():
137
- sqs_source = SQSTaskSource(burst_mode=burst)
163
+ sqs_source = SQSTaskSource(
164
+ burst_mode=burst,
165
+ wait_for_tasks=not no_wait,
166
+ task_timeout=float(timeout) if timeout else None,
167
+ )
138
168
  try:
139
169
  return await sqs_source.start()
140
170
  except KeyboardInterrupt:
@@ -149,6 +179,22 @@ def serve(host: str, port: int, reload: bool, env_file: Path, dev: bool, source:
149
179
  if result.messages_processed == 0:
150
180
  click.echo("Queue was empty, no messages processed")
151
181
  exit(42)
182
+ elif no_wait:
183
+ # Not waiting for tasks - just report messages processed
184
+ click.echo(f"Burst complete: queued {result.messages_processed} message(s)")
185
+ click.echo(" (not waiting for container tasks to complete)")
186
+ exit(0)
187
+ elif result.tasks_completed == result.messages_processed:
188
+ # All tasks completed successfully
189
+ click.echo(f"Burst complete: processed {result.messages_processed} message(s), "
190
+ f"all {result.tasks_completed} task(s) completed")
191
+ exit(0)
192
+ elif result.tasks_completed < result.messages_processed:
193
+ # Timeout - some tasks didn't complete
194
+ remaining = result.messages_processed - result.tasks_completed
195
+ click.echo(f"Burst timeout: processed {result.messages_processed} message(s), "
196
+ f"but {remaining} task(s) still running")
197
+ exit(43)
152
198
  else:
153
199
  click.echo(f"Burst complete: processed {result.messages_processed} message(s)")
154
200
  exit(0)
@@ -23,6 +23,7 @@ logger = structlog.get_logger()
23
23
  class BurstResult:
24
24
  """Result of a burst mode SQS run."""
25
25
  messages_processed: int
26
+ tasks_completed: int = 0
26
27
  errors: int = 0
27
28
 
28
29
 
@@ -44,7 +45,13 @@ class SQSTaskSource(TaskSource):
44
45
  }
45
46
  """
46
47
 
47
- def __init__(self, task_processor: Optional[TaskProcessor] = None, burst_mode: bool = False):
48
+ def __init__(
49
+ self,
50
+ task_processor: Optional[TaskProcessor] = None,
51
+ burst_mode: bool = False,
52
+ wait_for_tasks: bool = True,
53
+ task_timeout: Optional[float] = None,
54
+ ):
48
55
  """Initialize SQS task source.
49
56
 
50
57
  Args:
@@ -52,13 +59,21 @@ class SQSTaskSource(TaskSource):
52
59
  a new one will be created.
53
60
  burst_mode: If True, process all available messages and exit instead
54
61
  of polling indefinitely.
62
+ wait_for_tasks: If True (default), burst mode will wait for all
63
+ queued tasks to complete before exiting. If False,
64
+ exits as soon as SQS queue is drained.
65
+ task_timeout: Optional timeout in seconds for waiting on task completion
66
+ in burst mode. If None, waits indefinitely.
55
67
  """
56
68
  self.task_processor = task_processor or TaskProcessor()
57
69
  self.config = get_config()
58
70
  self._running = False
59
71
  self._poll_task: Optional[asyncio.Task] = None
60
72
  self._burst_mode = burst_mode
73
+ self._wait_for_tasks = wait_for_tasks
74
+ self._task_timeout = task_timeout
61
75
  self._messages_processed = 0
76
+ self._tasks_completed = 0
62
77
  self._errors = 0
63
78
 
64
79
  # Import boto3 lazily to avoid requiring it for webhook-only deployments
@@ -80,6 +95,8 @@ class SQSTaskSource(TaskSource):
80
95
  queue_url=self.config.aws_sqs_queue_url,
81
96
  region=self.config.aws_region,
82
97
  burst_mode=self._burst_mode,
98
+ wait_for_tasks=self._wait_for_tasks,
99
+ task_timeout=self._task_timeout,
83
100
  )
84
101
 
85
102
  async def start(self) -> Optional[BurstResult]:
@@ -116,10 +133,16 @@ class SQSTaskSource(TaskSource):
116
133
  async def _run_burst_mode(self) -> BurstResult:
117
134
  """Run in burst mode: process all available messages then exit.
118
135
 
136
+ If wait_for_tasks is True (default), this will wait for all queued
137
+ tasks to complete before returning. This ensures that Docker jobs
138
+ (e.g., Claude executions) have finished, not just been queued.
139
+
119
140
  Returns:
120
- BurstResult with count of messages processed.
141
+ BurstResult with count of messages processed and tasks completed.
121
142
  """
122
- logger.info("Running in burst mode - will drain queue and exit")
143
+ logger.info("Running in burst mode - will drain queue and exit",
144
+ wait_for_tasks=self._wait_for_tasks,
145
+ task_timeout=self._task_timeout)
123
146
 
124
147
  # Track if we found any messages on the first poll
125
148
  first_poll = True
@@ -135,7 +158,7 @@ class SQSTaskSource(TaskSource):
135
158
  else:
136
159
  # We've drained the queue
137
160
  logger.info(
138
- "Queue drained",
161
+ "SQS queue drained",
139
162
  messages_processed=self._messages_processed,
140
163
  errors=self._errors,
141
164
  )
@@ -156,8 +179,37 @@ class SQSTaskSource(TaskSource):
156
179
  exc_info=True,
157
180
  )
158
181
 
182
+ # Now wait for all queued tasks to complete (if enabled)
183
+ if self._wait_for_tasks and self._messages_processed > 0:
184
+ container_pool = self.task_processor.container_pool
185
+ queued_count = container_pool.get_total_queued_tasks()
186
+
187
+ logger.info("SQS queue drained, waiting for container tasks to complete",
188
+ queued_tasks=queued_count,
189
+ timeout=self._task_timeout)
190
+
191
+ all_completed = await container_pool.wait_for_all_tasks_complete(
192
+ timeout=self._task_timeout
193
+ )
194
+
195
+ if all_completed:
196
+ self._tasks_completed = self._messages_processed
197
+ logger.info("All container tasks completed successfully",
198
+ tasks_completed=self._tasks_completed)
199
+ else:
200
+ # Timeout occurred - some tasks may still be running
201
+ remaining = container_pool.get_total_queued_tasks()
202
+ self._tasks_completed = self._messages_processed - remaining
203
+ logger.warning("Timeout waiting for container tasks",
204
+ tasks_completed=self._tasks_completed,
205
+ tasks_remaining=remaining)
206
+ else:
207
+ # Not waiting for tasks, or no messages processed
208
+ self._tasks_completed = 0
209
+
159
210
  return BurstResult(
160
211
  messages_processed=self._messages_processed,
212
+ tasks_completed=self._tasks_completed,
161
213
  errors=self._errors,
162
214
  )
163
215