drydock-cli 3.0.104__tar.gz → 3.0.106__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 (119) hide show
  1. {drydock_cli-3.0.104/drydock_cli.egg-info → drydock_cli-3.0.106}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/agent.py +15 -1
  3. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/config.py +5 -0
  4. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/providers.py +36 -9
  5. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tools/__init__.py +38 -5
  6. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tuning.py +12 -0
  7. {drydock_cli-3.0.104 → drydock_cli-3.0.106/drydock_cli.egg-info}/PKG-INFO +1 -1
  8. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock_cli.egg-info/SOURCES.txt +2 -0
  9. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/pyproject.toml +1 -1
  10. drydock_cli-3.0.106/tests/test_bash_crossplatform.py +49 -0
  11. drydock_cli-3.0.106/tests/test_stall_retry.py +53 -0
  12. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/LICENSE +0 -0
  13. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/NOTICE +0 -0
  14. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/README.md +0 -0
  15. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/__init__.py +0 -0
  16. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/__main__.py +0 -0
  17. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/advisor.py +0 -0
  18. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/bash_safety.py +0 -0
  19. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/builtin_skills/__init__.py +0 -0
  20. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/builtin_skills/rmf-categorize.md +0 -0
  21. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/builtin_skills/rmf-control.md +0 -0
  22. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/builtin_skills/rmf-poam.md +0 -0
  23. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/builtin_skills/rmf-review.md +0 -0
  24. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/builtin_skills/stig-assess.md +0 -0
  25. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/builtin_skills/stig-remediate.md +0 -0
  26. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/cci.py +0 -0
  27. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/cli.py +0 -0
  28. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/compaction.py +0 -0
  29. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/detect.py +0 -0
  30. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/extract.py +0 -0
  31. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/gittools.py +0 -0
  32. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/graphrag.py +0 -0
  33. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/guards.py +0 -0
  34. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/loop_detect.py +0 -0
  35. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/mcp.py +0 -0
  36. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/poam.py +0 -0
  37. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/rmf.py +0 -0
  38. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/rmf_graph.py +0 -0
  39. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/skills.py +0 -0
  40. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/stig.py +0 -0
  41. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tool_registry.py +0 -0
  42. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tui/__init__.py +0 -0
  43. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tui/app.py +0 -0
  44. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tui/approval.py +0 -0
  45. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tui/messages.py +0 -0
  46. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tui/widgets.py +0 -0
  47. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/web.py +0 -0
  48. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock_cli.egg-info/dependency_links.txt +0 -0
  49. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock_cli.egg-info/entry_points.txt +0 -0
  50. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock_cli.egg-info/requires.txt +0 -0
  51. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock_cli.egg-info/top_level.txt +0 -0
  52. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/setup.cfg +0 -0
  53. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_advisor.py +0 -0
  54. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_approval.py +0 -0
  55. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_back_command.py +0 -0
  56. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_background.py +0 -0
  57. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_binary_output.py +0 -0
  58. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_output_bounding.py +0 -0
  59. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_process_group.py +0 -0
  60. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_safety.py +0 -0
  61. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_sanitize.py +0 -0
  62. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_shell.py +0 -0
  63. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_stdin.py +0 -0
  64. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_stop_partial.py +0 -0
  65. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_timeout_network.py +0 -0
  66. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_timeout_param.py +0 -0
  67. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_cci.py +0 -0
  68. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_cli_agents.py +0 -0
  69. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_compact_command.py +0 -0
  70. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_compaction.py +0 -0
  71. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_config.py +0 -0
  72. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_config_migration.py +0 -0
  73. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_context_limit_config.py +0 -0
  74. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_detect.py +0 -0
  75. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_dispatch.py +0 -0
  76. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_e2e_connected.py +0 -0
  77. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_edit_replace_all.py +0 -0
  78. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_empty_response.py +0 -0
  79. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_extract.py +0 -0
  80. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_failure_loop.py +0 -0
  81. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_first_run_setup.py +0 -0
  82. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_gittools.py +0 -0
  83. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_glob_edit_edges.py +0 -0
  84. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_graphify_example.py +0 -0
  85. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_graphrag.py +0 -0
  86. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_grep_and_read_robust.py +0 -0
  87. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_guards_and_tools.py +0 -0
  88. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_hallucinated_tools.py +0 -0
  89. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_leaked_tool_call.py +0 -0
  90. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_loop_detect.py +0 -0
  91. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_mcp.py +0 -0
  92. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_oneshot_unreachable.py +0 -0
  93. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_plan_autocontinue.py +0 -0
  94. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_poam.py +0 -0
  95. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_providers_unreachable.py +0 -0
  96. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_read_index.py +0 -0
  97. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_rmf.py +0 -0
  98. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_rmf_graph.py +0 -0
  99. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_rmf_stig_graph.py +0 -0
  100. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_runaway_repetition.py +0 -0
  101. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_server_probe.py +0 -0
  102. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_skills.py +0 -0
  103. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_stig.py +0 -0
  104. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_stop.py +0 -0
  105. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_streaming_newlines.py +0 -0
  106. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_subagent.py +0 -0
  107. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_system_prompt_help.py +0 -0
  108. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_todo.py +0 -0
  109. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_tool_arg_coercion.py +0 -0
  110. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_tool_arg_coercion_more.py +0 -0
  111. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_tool_arg_parsing.py +0 -0
  112. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_tools_undo.py +0 -0
  113. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_tui.py +0 -0
  114. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_tuning.py +0 -0
  115. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_viewimage.py +0 -0
  116. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_vision_input.py +0 -0
  117. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_web_tools.py +0 -0
  118. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_write_content_coerce.py +0 -0
  119. {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.104
3
+ Version: 3.0.106
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -19,7 +19,7 @@ def _plan_has_unfinished(config: dict) -> bool:
19
19
  todo = config.get("_todo")
20
20
  return bool(todo) and any(status != "done" for _, status in todo)
21
21
 
22
- from drydock.providers import stream, AssistantTurn, ReasoningChunk, TextChunk
22
+ from drydock.providers import stream, AssistantTurn, ReasoningChunk, TextChunk, StallRetry
23
23
  from drydock.tool_registry import schemas, execute
24
24
  from drydock.tools import register_all
25
25
 
@@ -159,6 +159,7 @@ def run(
159
159
 
160
160
  # Stream from LLM — with retry on context-length 400 error
161
161
  retries = 0
162
+ stall_retries = 0
162
163
  while retries < 2:
163
164
  try:
164
165
  available = schemas()
@@ -178,6 +179,19 @@ def run(
178
179
  elif isinstance(event, AssistantTurn):
179
180
  assistant_turn = event
180
181
  break # success
182
+ except StallRetry:
183
+ # The local server hung mid-generation (config stall_retry_secs).
184
+ # Re-issue the same request — a fresh generation usually isn't
185
+ # stalled. Bounded so a truly-dead server can't loop forever; on
186
+ # exhaustion end the turn cleanly. Stalls have their OWN budget,
187
+ # separate from the context-error `retries`.
188
+ stall_retries += 1
189
+ if stall_retries > 3:
190
+ yield TextChunk("\n[model server kept stalling — giving up on this step.]\n")
191
+ assistant_turn = None
192
+ break
193
+ yield TextChunk("\n[model server stalled — retrying...]\n")
194
+ continue
181
195
  except Exception as e:
182
196
  err = str(e)
183
197
  if is_context_length_error(err):
@@ -34,6 +34,11 @@ DEFAULTS: dict[str, object] = {
34
34
  # real window BEFORE compacting (the server 400s) — set this to your -c
35
35
  # value. Default 65536 matches the bundled gemma4 server (-c 65536).
36
36
  "context_limit": 65536,
37
+ # Auto-retry a hung model call: if the server produces NOTHING for this many
38
+ # seconds, drydock abandons the wedged request and re-issues it (a fresh
39
+ # generation usually isn't stalled — a known gemma/llama.cpp hang). 0 = off.
40
+ # Set to e.g. 600 on a stall-prone local server. Bounded to a few retries.
41
+ "stall_retry_secs": 0,
37
42
  "theme": "harbor",
38
43
  # Optional SECOND model ("advisor") for a stronger second opinion — e.g. a
39
44
  # Gemini OpenAI-compatible endpoint on another box. Empty = disabled. The
@@ -23,6 +23,13 @@ _LLM_POOL = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_pre
23
23
  class _StopRequested(Exception):
24
24
  """Internal: STOP was pressed during a blocking LLM call."""
25
25
 
26
+
27
+ class StallRetry(Exception):
28
+ """The model server produced no progress for `stall_retry_secs` — the local
29
+ server has hung mid-generation (a known gemma/llama.cpp failure). The agent
30
+ abandons the wedged request and re-issues it; a fresh generation usually
31
+ doesn't stall. Opt-in via config `stall_retry_secs` (0 = off)."""
32
+
26
33
  from drydock.loop_detect import runaway_repetition_len
27
34
  from drydock.tuning import extract_thinking, strip_leaked_tool_calls, strip_thinking_tokens, use_streaming
28
35
 
@@ -138,19 +145,26 @@ def _safe_create(client, kwargs: dict, base_url: str, provider: str, timeout_s:
138
145
 
139
146
 
140
147
  def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel,
141
- timeout_s: float = 600.0):
148
+ timeout_s: float = 600.0, stall_secs: float = 0.0):
142
149
  """Like _safe_create, but runs off-thread and polls a cancel Event so STOP
143
150
  can abandon a blocked decode (raising _StopRequested). The orphaned request
144
- runs to completion in the pool thread and its result is dropped."""
151
+ runs to completion in the pool thread and its result is dropped.
152
+
153
+ If stall_secs > 0 and the request produces nothing in that long, raise
154
+ StallRetry so the caller can abandon a hung server and re-issue."""
145
155
  import openai
156
+ import time as _time
146
157
 
147
158
  fut = _LLM_POOL.submit(client.chat.completions.create, **kwargs)
159
+ start = _time.monotonic()
148
160
  while True:
149
161
  try:
150
162
  return fut.result(timeout=0.2)
151
163
  except concurrent.futures.TimeoutError:
152
164
  if cancel is not None and cancel.is_set():
153
165
  raise _StopRequested
166
+ if stall_secs and (_time.monotonic() - start) > stall_secs:
167
+ raise StallRetry(stall_secs) # orphan the wedged request; caller retries
154
168
  # APITimeoutError subclasses APIConnectionError — catch it FIRST.
155
169
  except openai.APITimeoutError as e:
156
170
  raise LLMUnreachable(_friendly_timeout(base_url, timeout_s)) from e
@@ -158,12 +172,17 @@ def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel
158
172
  raise LLMUnreachable(_friendly_unreachable(base_url, provider)) from e
159
173
 
160
174
 
161
- def _stream_chunks(response, cancel):
175
+ def _stream_chunks(response, cancel, stall_secs: float = 0.0):
162
176
  """Yield streamed chunks, but pull them via a background thread + queue so a
163
177
  STOP can abandon a read that's blocked waiting for the first token (the
164
178
  thinking phase). On cancel we just stop reading; the orphaned producer
165
- drains in the background."""
179
+ drains in the background.
180
+
181
+ If stall_secs > 0 and no chunk arrives for that long, raise StallRetry — a
182
+ hung server. (A slow-but-alive stream still emits chunks, which reset the
183
+ timer, so this fires only on a true stall.)"""
166
184
  import queue as _queue
185
+ import time as _time
167
186
 
168
187
  q: _queue.Queue = _queue.Queue()
169
188
  _DONE = object()
@@ -178,13 +197,17 @@ def _stream_chunks(response, cancel):
178
197
  q.put(_DONE)
179
198
 
180
199
  _LLM_POOL.submit(_produce)
200
+ last = _time.monotonic()
181
201
  while True:
182
202
  if cancel is not None and cancel.is_set():
183
203
  return
184
204
  try:
185
205
  item = q.get(timeout=0.2)
186
206
  except _queue.Empty:
207
+ if stall_secs and (_time.monotonic() - last) > stall_secs:
208
+ raise StallRetry(stall_secs)
187
209
  continue
210
+ last = _time.monotonic()
188
211
  if item is _DONE:
189
212
  return
190
213
  if isinstance(item, Exception):
@@ -410,6 +433,10 @@ def stream(
410
433
  read_timeout = float(config.get("request_timeout") or 1800.0)
411
434
  except (TypeError, ValueError):
412
435
  read_timeout = 1800.0
436
+ try:
437
+ stall_secs = float(config.get("stall_retry_secs") or 0) # 0 = disabled
438
+ except (TypeError, ValueError):
439
+ stall_secs = 0.0
413
440
  client = OpenAI(
414
441
  api_key=api_key,
415
442
  base_url=base_url,
@@ -461,7 +488,7 @@ def stream(
461
488
  # streaming. If STOP closes the client mid-call the blocking request
462
489
  # raises — swallow that as a clean cancel rather than an error.
463
490
  try:
464
- yield from _complete_nonstreaming(client, kwargs, base_url, provider, cancel, read_timeout)
491
+ yield from _complete_nonstreaming(client, kwargs, base_url, provider, cancel, read_timeout, stall_secs)
465
492
  except _StopRequested:
466
493
  return # STOP — abandon the in-flight request, clean stop
467
494
  except Exception:
@@ -482,11 +509,11 @@ def stream(
482
509
  _rep_checked_at = 0
483
510
 
484
511
  try:
485
- response = _create_abortable(client, kwargs, base_url, provider, cancel, read_timeout)
512
+ response = _create_abortable(client, kwargs, base_url, provider, cancel, read_timeout, stall_secs)
486
513
  except _StopRequested:
487
514
  config.get("_abort", {}).pop("client", None)
488
515
  return
489
- for chunk in _stream_chunks(response, cancel):
516
+ for chunk in _stream_chunks(response, cancel, stall_secs):
490
517
  if not chunk.choices:
491
518
  if hasattr(chunk, "usage") and chunk.usage:
492
519
  in_tok = chunk.usage.prompt_tokens or in_tok
@@ -557,13 +584,13 @@ def stream(
557
584
 
558
585
  def _complete_nonstreaming(
559
586
  client, kwargs: dict, base_url: str = "", provider: str = "", cancel=None,
560
- timeout_s: float = 600.0,
587
+ timeout_s: float = 600.0, stall_secs: float = 0.0,
561
588
  ) -> Generator:
562
589
  """Single non-streaming completion. Yields one TextChunk (if any text)
563
590
  then the AssistantTurn. Used when streaming would corrupt tool-call JSON.
564
591
  Runs off-thread so STOP can abandon a blocked decode.
565
592
  """
566
- resp = _create_abortable(client, kwargs, base_url, provider, cancel, timeout_s)
593
+ resp = _create_abortable(client, kwargs, base_url, provider, cancel, timeout_s, stall_secs)
567
594
  choice = resp.choices[0]
568
595
  msg = choice.message
569
596
 
@@ -82,14 +82,20 @@ def _detect_bash() -> str | None:
82
82
  found = shutil.which("bash")
83
83
  if found:
84
84
  return found
85
- for p in ("/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"):
85
+ # Common absolute locations, incl. Git-for-Windows and WSL bash on Windows.
86
+ for p in ("/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash",
87
+ r"C:\Program Files\Git\bin\bash.exe",
88
+ r"C:\Program Files (x86)\Git\bin\bash.exe",
89
+ r"C:\Windows\System32\bash.exe"):
86
90
  if os.path.exists(p):
87
91
  return p
88
92
  return None
89
93
 
90
94
 
91
- # Resolved once at import (in whatever environment drydock runs — host or the
92
- # task container). None bash unavailable, fall back to the default shell.
95
+ _IS_WINDOWS = os.name == "nt"
96
+ # Resolved once at import (in whatever environment drydock runs — host, the task
97
+ # container, Linux, or Windows). None → bash unavailable, fall back to the
98
+ # platform's default shell (cmd.exe on Windows, /bin/sh on POSIX).
93
99
  _BASH_SHELL = _detect_bash()
94
100
 
95
101
 
@@ -928,6 +934,18 @@ def kill_process_group(proc) -> None:
928
934
  if the group can't be resolved (e.g. process already gone)."""
929
935
  if proc is None:
930
936
  return
937
+ if _IS_WINDOWS:
938
+ # No process groups / SIGKILL on Windows — taskkill /T kills the whole
939
+ # child tree (the shell + everything it spawned).
940
+ try:
941
+ subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
942
+ capture_output=True, timeout=10)
943
+ except Exception: # noqa: BLE001
944
+ try:
945
+ proc.kill()
946
+ except Exception: # noqa: BLE001
947
+ pass
948
+ return
931
949
  try:
932
950
  os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
933
951
  except (ProcessLookupError, PermissionError, OSError):
@@ -1016,9 +1034,19 @@ def tool_bash(params: dict, config: dict) -> str:
1016
1034
  # overall timeout between slices.
1017
1035
  cancel = config.get("_cancel")
1018
1036
  proc = None
1037
+ # Invoke bash explicitly as [bash, "-c", cmd] rather than shell=True +
1038
+ # executable — cross-platform correctness: on Windows shell=True builds
1039
+ # `{executable} /c {cmd}` (cmd.exe syntax), so a real bash.exe (Git Bash/WSL)
1040
+ # would be called as `bash /c ...` and fail. `[bash, "-c", cmd]` runs the same
1041
+ # on Linux and Windows. Without bash, fall back to the platform default shell
1042
+ # (cmd.exe on Windows, /bin/sh on POSIX) via shell=True.
1043
+ if _BASH_SHELL:
1044
+ popen_cmd, use_shell = [_BASH_SHELL, "-c", cmd], False
1045
+ else:
1046
+ popen_cmd, use_shell = cmd, True
1019
1047
  try:
1020
1048
  proc = subprocess.Popen(
1021
- cmd, shell=True, executable=_BASH_SHELL,
1049
+ popen_cmd, shell=use_shell,
1022
1050
  stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
1023
1051
  # stdin=DEVNULL so a command that reads stdin gets immediate EOF
1024
1052
  # (correct for a non-interactive tool) instead of inheriting the
@@ -1033,7 +1061,12 @@ def tool_bash(params: dict, config: dict) -> str:
1033
1061
  # which killed the thread and handed the agent "(no output)", losing
1034
1062
  # even the text parts of mixed output.
1035
1063
  text=True, encoding="utf-8", errors="replace",
1036
- cwd=config.get("cwd"), start_new_session=True,
1064
+ cwd=config.get("cwd"),
1065
+ # Own process group / job so kill_process_group takes down the whole
1066
+ # tree: setsid on POSIX; a new process group on Windows (getattr keeps
1067
+ # the Windows-only flag out of the POSIX code path — it resolves to 0).
1068
+ start_new_session=not _IS_WINDOWS,
1069
+ creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
1037
1070
  )
1038
1071
  config.setdefault("_abort", {})["proc"] = proc
1039
1072
  # Read output in a daemon thread with a HARD byte cap, so memory can't
@@ -91,6 +91,12 @@ _DEFAULT_SYSTEM_PROMPT = (
91
91
  "NOT stop to ask whether to proceed. Call `todo` again to flip a step to "
92
92
  "done and the next to in-progress as you finish each, and only stop when "
93
93
  "every step is done. Don't write a TODO.md file for this.\n"
94
+ "Before you finish, RE-READ the task and confirm EVERY requirement is met — "
95
+ "exact filenames, paths, formats, and values matter, so check them literally. "
96
+ "If the task ships a test/eval/check script (eval.py, test.sh, a make target), "
97
+ "RUN it and fix failures until it passes before you stop. Prefer acting — write "
98
+ "files and run commands — over long planning; don't produce a big plan without "
99
+ "taking a concrete step.\n"
94
100
  "You have VISION: when the user references an image file path (.png/.jpg/"
95
101
  ".jpeg/.gif/.webp/.bmp on disk — a screenshot, mockup, diagram, or photo), it "
96
102
  "is attached and you can SEE it; you can also call the `ViewImage` tool to "
@@ -121,6 +127,12 @@ _GEMMA_SYSTEM_PROMPT = (
121
127
  "steps — do NOT stop to ask whether to proceed. Call `todo` again to update "
122
128
  "it as you finish each step, and only stop when every step is done. Do not "
123
129
  "write a TODO.md file for this.\n"
130
+ "Before you finish, RE-READ the task and confirm EVERY requirement is met — "
131
+ "exact filenames, paths, formats, and values matter, so check them literally. "
132
+ "If the task ships a test/eval/check script (eval.py, test.sh, a make target), "
133
+ "RUN it and fix failures until it passes before you stop. Prefer acting — write "
134
+ "files and run commands — over long planning; don't produce a big plan without "
135
+ "taking a concrete step.\n"
124
136
  "You have VISION: when the user's message references an image file path "
125
137
  "(.png/.jpg/.jpeg/.gif/.webp/.bmp that exists on disk — a screenshot, mockup, "
126
138
  "diagram, or photo), that image is attached and you can SEE it. You can also "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.104
3
+ Version: 3.0.106
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -51,6 +51,7 @@ tests/test_approval.py
51
51
  tests/test_back_command.py
52
52
  tests/test_bash_background.py
53
53
  tests/test_bash_binary_output.py
54
+ tests/test_bash_crossplatform.py
54
55
  tests/test_bash_output_bounding.py
55
56
  tests/test_bash_process_group.py
56
57
  tests/test_bash_safety.py
@@ -96,6 +97,7 @@ tests/test_rmf_stig_graph.py
96
97
  tests/test_runaway_repetition.py
97
98
  tests/test_server_probe.py
98
99
  tests/test_skills.py
100
+ tests/test_stall_retry.py
99
101
  tests/test_stig.py
100
102
  tests/test_stop.py
101
103
  tests/test_streaming_newlines.py
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
  # PyPI distribution name is drydock-cli (the established install name, continued
8
8
  # from the retired v2 fork); the import package + CLI command stay `drydock`.
9
9
  name = "drydock-cli"
10
- version = "3.0.104"
10
+ version = "3.0.106"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -0,0 +1,49 @@
1
+ """tool_bash must run on Linux AND Windows. The Windows blocker was shell=True +
2
+ executable=bash → Popen builds `bash /c cmd` (cmd.exe syntax) which a real
3
+ bash.exe rejects. Fix: invoke `[bash, "-c", cmd]` explicitly; use taskkill on
4
+ Windows for the process-tree kill; setsid only on POSIX."""
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import subprocess
9
+
10
+ from drydock import tools
11
+ from drydock.tools import _detect_bash, _IS_WINDOWS, kill_process_group, tool_bash
12
+
13
+
14
+ def test_platform_flag_matches_os():
15
+ assert _IS_WINDOWS == (os.name == "nt")
16
+
17
+
18
+ def test_detect_bash_returns_path_or_none():
19
+ r = _detect_bash()
20
+ assert r is None or (isinstance(r, str) and "bash" in r.lower())
21
+
22
+
23
+ def test_bash_invoked_as_argv_not_shell_c(monkeypatch):
24
+ """When bash is available, Popen is called with [bash, '-c', cmd] and
25
+ shell=False — never shell=True+executable (which breaks on Windows)."""
26
+ if not tools._BASH_SHELL:
27
+ return
28
+ seen = {}
29
+ real = subprocess.Popen
30
+
31
+ def spy(popen_cmd, *a, **kw):
32
+ seen["cmd"] = popen_cmd
33
+ seen["shell"] = kw.get("shell")
34
+ return real(popen_cmd, *a, **kw)
35
+
36
+ monkeypatch.setattr(subprocess, "Popen", spy)
37
+ tool_bash({"command": "echo hi", "timeout": 5}, {"cwd": "/tmp"})
38
+ assert seen["shell"] is False
39
+ assert isinstance(seen["cmd"], list) and seen["cmd"][1] == "-c"
40
+ assert seen["cmd"][0].lower().endswith(("bash", "bash.exe"))
41
+
42
+
43
+ def test_kill_process_group_none_is_safe():
44
+ kill_process_group(None) # must not raise
45
+
46
+
47
+ def test_command_still_runs_here():
48
+ out = tool_bash({"command": "echo cross_platform_ok", "timeout": 5}, {"cwd": "/tmp"})
49
+ assert "cross_platform_ok" in out
@@ -0,0 +1,53 @@
1
+ """Auto-retry on server stall (config stall_retry_secs, default 0=off): a hung
2
+ model call (server produces nothing for stall_secs) raises StallRetry so the
3
+ agent abandons the wedged request and re-issues it. Plus the self-verify /
4
+ run-test-script / act prompt guidance."""
5
+ from __future__ import annotations
6
+
7
+ import time
8
+
9
+ from drydock.providers import StallRetry, _create_abortable
10
+ from drydock import config
11
+ from drydock.tuning import system_prompt_for_model
12
+
13
+
14
+ class _HangingClient:
15
+ class chat:
16
+ class completions:
17
+ @staticmethod
18
+ def create(**kw):
19
+ time.sleep(60) # simulate a wedged server
20
+
21
+
22
+ def test_stall_retry_raised_on_hang():
23
+ t0 = time.monotonic()
24
+ raised = False
25
+ try:
26
+ _create_abortable(_HangingClient(), {}, "url", "vllm", None,
27
+ timeout_s=600, stall_secs=1)
28
+ except StallRetry:
29
+ raised = True
30
+ assert raised and (time.monotonic() - t0) < 4
31
+
32
+
33
+ def test_stall_retry_disabled_by_default():
34
+ assert config.DEFAULTS.get("stall_retry_secs") == 0
35
+
36
+
37
+ def test_no_stall_when_secs_zero_returns_quickly():
38
+ # stall_secs=0 → never raises StallRetry; a fast create returns normally
39
+ class Fast:
40
+ class chat:
41
+ class completions:
42
+ @staticmethod
43
+ def create(**kw):
44
+ return "ok"
45
+ assert _create_abortable(Fast(), {}, "u", "vllm", None, stall_secs=0) == "ok"
46
+
47
+
48
+ def test_prompts_have_verify_and_act_guidance():
49
+ for m in ("gemma4", "some-other-model"):
50
+ p = system_prompt_for_model(m)
51
+ assert "RE-READ the task" in p
52
+ assert "eval.py" in p and "test.sh" in p # run-the-test-script
53
+ assert "Prefer acting" in p # act, don't over-plan
File without changes
File without changes
File without changes
File without changes