superwait 0.3.0__tar.gz → 0.3.1__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 (27) hide show
  1. {superwait-0.3.0 → superwait-0.3.1}/PKG-INFO +4 -1
  2. {superwait-0.3.0 → superwait-0.3.1}/README.md +3 -0
  3. {superwait-0.3.0 → superwait-0.3.1}/docs/hosts.md +23 -0
  4. {superwait-0.3.0 → superwait-0.3.1}/pyproject.toml +1 -1
  5. {superwait-0.3.0 → superwait-0.3.1}/src/superwait/SKILL.md +7 -3
  6. {superwait-0.3.0 → superwait-0.3.1}/src/superwait/cli.py +7 -0
  7. {superwait-0.3.0 → superwait-0.3.1}/src/superwait/engine.py +27 -3
  8. {superwait-0.3.0 → superwait-0.3.1}/src/superwait/models.py +21 -6
  9. {superwait-0.3.0 → superwait-0.3.1}/src/superwait/references/api.md +40 -5
  10. {superwait-0.3.0 → superwait-0.3.1}/src/superwait/server.py +5 -3
  11. {superwait-0.3.0 → superwait-0.3.1}/src/superwait/setup.py +3 -1
  12. {superwait-0.3.0 → superwait-0.3.1}/src/superwait/store.py +13 -0
  13. superwait-0.3.1/tests/test_observations.py +123 -0
  14. {superwait-0.3.0 → superwait-0.3.1}/tests/test_transport.py +14 -0
  15. {superwait-0.3.0 → superwait-0.3.1}/uv.lock +1 -1
  16. {superwait-0.3.0 → superwait-0.3.1}/.gitignore +0 -0
  17. {superwait-0.3.0 → superwait-0.3.1}/CONTRIBUTING.md +0 -0
  18. {superwait-0.3.0 → superwait-0.3.1}/LICENSE +0 -0
  19. {superwait-0.3.0 → superwait-0.3.1}/docs/assets/superwait-banner.png +0 -0
  20. {superwait-0.3.0 → superwait-0.3.1}/docs/reference.md +0 -0
  21. {superwait-0.3.0 → superwait-0.3.1}/docs/savings.md +0 -0
  22. {superwait-0.3.0 → superwait-0.3.1}/src/superwait/__init__.py +0 -0
  23. {superwait-0.3.0 → superwait-0.3.1}/src/superwait/__main__.py +0 -0
  24. {superwait-0.3.0 → superwait-0.3.1}/src/superwait/hooks.py +0 -0
  25. {superwait-0.3.0 → superwait-0.3.1}/tests/test_ergonomics.py +0 -0
  26. {superwait-0.3.0 → superwait-0.3.1}/tests/test_hooks_setup.py +0 -0
  27. {superwait-0.3.0 → superwait-0.3.1}/tests/test_wait.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: superwait
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Conditional waiting for coding agents
5
5
  Project-URL: Repository, https://github.com/ctxrs/superwait
6
6
  Project-URL: Issues, https://github.com/ctxrs/superwait/issues
@@ -50,6 +50,9 @@ superwait setup cursor
50
50
  ```
51
51
 
52
52
  Restart your coding agent and review its normal MCP and hook trust prompts. Set up before spawning workers so their lifecycle events can be recorded.
53
+ For Codex, open `/hooks` and trust the three superwait hooks. After spawning a
54
+ worker, `superwait doctor codex` checks whether its events are reaching superwait.
55
+ See [installation checks](https://github.com/ctxrs/superwait/blob/main/docs/hosts.md#confirm-the-installation-is-active) if they aren't.
53
56
 
54
57
  Then ask your agent:
55
58
 
@@ -36,6 +36,9 @@ superwait setup cursor
36
36
  ```
37
37
 
38
38
  Restart your coding agent and review its normal MCP and hook trust prompts. Set up before spawning workers so their lifecycle events can be recorded.
39
+ For Codex, open `/hooks` and trust the three superwait hooks. After spawning a
40
+ worker, `superwait doctor codex` checks whether its events are reaching superwait.
41
+ See [installation checks](https://github.com/ctxrs/superwait/blob/main/docs/hosts.md#confirm-the-installation-is-active) if they aren't.
39
42
 
40
43
  Then ask your agent:
41
44
 
@@ -76,6 +76,11 @@ no history. It is version-dependent; absent or unsupported metadata leaves
76
76
  UUID-based waiting and explicit signals available.
77
77
 
78
78
  A previously unknown task path remains pending until that mapping arrives.
79
+ If no worker in that parent is running, an unobserved path instead returns a
80
+ setup diagnostic after five seconds. Unobserved native IDs use the same grace
81
+ period; already observed workers can run for the full deadline.
82
+ An `any` or quorum wait can continue without an unobserved target when its other
83
+ targets can still satisfy the required count.
79
84
  Task paths require `session` even if only one old mapping exists: an unscoped
80
85
  name could otherwise match a previous conversation before the new worker stops.
81
86
  Setup installs a SessionStart hook that supplies this context to Codex; it
@@ -102,6 +107,24 @@ Sources: [hooks](https://developers.openai.com/codex/hooks),
102
107
  [MCP settings](https://learn.chatgpt.com/docs/extend/mcp),
103
108
  [skill discovery](https://learn.chatgpt.com/docs/build-skills).
104
109
 
110
+ ### Confirm the installation is active
111
+
112
+ Installing configuration does not approve its hooks. Open `/hooks`, review and
113
+ trust the three superwait definitions, then start a fresh session. Already-open
114
+ sessions may still use the previous configuration. Spawn a worker and check:
115
+
116
+ ```sh
117
+ superwait doctor codex
118
+ superwait agents codex
119
+ ```
120
+
121
+ Check that the new worker appears and its final report arrives. `doctor` shows
122
+ the package version, exact database, event count and latest event time; stored
123
+ old events alone do not prove the current host is recording. If observations
124
+ are missing, check hook trust, host reload, and matching database paths. Use
125
+ native agent waiting until this is fixed; file and command waits still work.
126
+ The CLI is also available when an existing session has not loaded the MCP tool.
127
+
105
128
  ## Claude Code
106
129
 
107
130
  The same lifecycle event names provide `agent_id` and `session_id`. Setup also
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "superwait"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Conditional waiting for coding agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,8 +5,8 @@ description: Prefer superwait for waiting on agents, files, services, or signals
5
5
 
6
6
  # superwait
7
7
 
8
- Prefer superwait over native wait tools or polling loops. Use native tools to
9
- spawn or steer agents.
8
+ Prefer superwait for observable conditions. Use native tools to spawn or steer
9
+ agents, or when superwait reports unobserved workers. Don't retry missing hooks.
10
10
 
11
11
  Call the `wait_for` MCP tool:
12
12
 
@@ -17,10 +17,14 @@ Call the `wait_for` MCP tool:
17
17
  Use handles returned by the host. For Codex `/root/...` paths, add `session`
18
18
  from startup context. The default waits for all agents. Read the `ready`
19
19
  reports; use `continue_wait` as the next `request` for remaining work.
20
+ Choose the real task deadline, not repeated 30–60-second timeouts. An expired
21
+ continuation stays expired. Native process handles still need their native tools.
20
22
 
21
23
  In Codex code mode, start `functions.exec` with
22
24
  `// @exec: {"yield_time_ms":60000}`. If it yields, resume that cell with
23
25
  `functions.wait` and `yield_time_ms: 60000`.
26
+ The wrapper can still wake the model; prefer a direct tool call when available.
24
27
 
25
28
  For other conditions, quorum, early wakeups, continuation details, or CLI usage,
26
- read the [local API reference](references/api.md) only when needed.
29
+ read the [local API reference](references/api.md) only when needed, including
30
+ the CLI fallback if the MCP tool is not exposed.
@@ -28,6 +28,9 @@ def parser():
28
28
  agents = commands.add_parser("agents", help="List recent hook-observed subagents.")
29
29
  agents.add_argument("provider", choices=["codex", "claude", "cursor"])
30
30
  agents.add_argument("--session")
31
+ doctor = commands.add_parser("doctor", help="Check whether lifecycle events are reaching this database.")
32
+ doctor.add_argument("provider", choices=["codex", "claude", "cursor"])
33
+ doctor.add_argument("--session", help="Check observations for one parent session.")
31
34
  hook = commands.add_parser("hook", help="Host lifecycle adapter; reads JSON on stdin.")
32
35
  hook.add_argument("provider", choices=["codex", "claude", "cursor"])
33
36
  commands.add_parser("serve", help="Serve the MCP API over stdio.")
@@ -74,6 +77,10 @@ def main():
74
77
  result = {"seq": store.emit("signal", args.key, args.state, data=data)}
75
78
  elif args.command == "agents":
76
79
  result = {"agents": store.agents(args.provider, args.session), "limit": 50}
80
+ elif args.command == "doctor":
81
+ from importlib.metadata import version
82
+ result = {"version": version("superwait"), **store.health(args.provider, args.session)}
83
+ code = 0 if result["status"] == "observed" else 1
77
84
  elif args.command == "hook":
78
85
  from .hooks import record_hook
79
86
  result = record_hook(args.provider, json.load(sys.stdin), store)
@@ -15,6 +15,9 @@ from .models import Agent, Command, File, HTTP, Signal, WaitRequest
15
15
  from .store import AmbiguousAgent, Store
16
16
 
17
17
 
18
+ AGENT_OBSERVATION_GRACE = 5.0
19
+
20
+
18
21
  def file_stamp(path):
19
22
  try:
20
23
  stat = path.stat()
@@ -86,7 +89,13 @@ async def observe(target, store, client, baseline=None):
86
89
  else:
87
90
  event = store.signal(target.key, target.state, target.after)
88
91
  matched = bool(event)
89
- return {"matched": bool(matched), "state": event["state"] if event else "unknown", "event": event}
92
+ result = {"matched": bool(matched), "state": event["state"] if event else "unknown", "event": event}
93
+ if not event and isinstance(target, Agent) and target.provider == "codex" and target.id.startswith("/"):
94
+ # Codex task paths are learned at Stop, not Start. A live
95
+ # worker in this parent can still supply the mapping.
96
+ result["awaiting_alias"] = any(a["state"] == "running"
97
+ for a in store.agents("codex", target.session, None))
98
+ return result
90
99
  if isinstance(target, File):
91
100
  path = Path(target.path).expanduser().absolute()
92
101
  stamp = file_stamp(path)
@@ -143,8 +152,20 @@ async def wait_for(request: WaitRequest, store: Store, progress=None):
143
152
  while True:
144
153
  checks += 1
145
154
  results[i] = await observe(targets[i], store, client, baselines[i])
155
+ target, result = targets[i], results[i]
156
+ if isinstance(target, Agent) and result.get("state") == "unknown":
157
+ result["hint"] = ("No lifecycle observation for this handle. Check superwait doctor "
158
+ + target.provider + "; review hook trust and use native waiting until recording works.")
159
+ if time.monotonic() - started >= AGENT_OBSERVATION_GRACE:
160
+ if not result.get("awaiting_alias"):
161
+ result.update(state="unobserved", error=result["hint"])
146
162
  changed.set()
147
- await asyncio.sleep(request.interval)
163
+ delay = request.interval
164
+ if isinstance(target, Agent) and result.get("state") == "unknown":
165
+ remaining = AGENT_OBSERVATION_GRACE - (time.monotonic() - started)
166
+ if remaining > 0:
167
+ delay = min(delay, max(.05, remaining))
168
+ await asyncio.sleep(delay)
148
169
 
149
170
  async def keepalive():
150
171
  while True:
@@ -172,7 +193,7 @@ async def wait_for(request: WaitRequest, store: Store, progress=None):
172
193
  c["transcript_path"] = data["transcript_path"]
173
194
  elif isinstance(t, (Agent, Signal)) and e["seq"] <= t.after:
174
195
  c["state"] = "awaiting_new_event"
175
- for key in ("error", "status_code", "exit_code", "stdout", "stderr"):
196
+ for key in ("error", "hint", "status_code", "exit_code", "stdout", "stderr"):
176
197
  if key in r and r[key] != "":
177
198
  c[key] = r[key]
178
199
  return c
@@ -237,6 +258,9 @@ async def wait_for(request: WaitRequest, store: Store, progress=None):
237
258
  return outcome("interrupted")
238
259
  if sum(r["matched"] for r in results[:len(primary)]) >= needed:
239
260
  return outcome("matched")
261
+ observable = sum(r.get("state") != "unobserved" for r in results[:len(primary)])
262
+ if observable < needed or any(r.get("state") == "unobserved" for r in results[len(primary):]):
263
+ return outcome("error", "An agent has no lifecycle observations. Check its handle and hook setup, or use native waiting.")
240
264
  finally:
241
265
  for worker in workers:
242
266
  worker.cancel()
@@ -5,7 +5,7 @@ import re
5
5
  from datetime import datetime, timezone
6
6
  from typing import Annotated, Literal
7
7
 
8
- from pydantic import BaseModel, ConfigDict, Field, model_validator
8
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
9
9
 
10
10
 
11
11
  class Model(BaseModel):
@@ -67,10 +67,15 @@ Target = Annotated[Agent | Signal | File | HTTP | Command, Field(discriminator="
67
67
 
68
68
 
69
69
  def seconds(value: str) -> float:
70
- match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?\s*", value)
71
- if not match:
72
- raise ValueError("duration must be a number with ms, s, m, h, or d (for example 2h)")
73
- result = float(match[1]) * {None: 1, "ms": .001, "s": 1, "m": 60, "h": 3600, "d": 86400}[match[2]]
70
+ number = r"\d+(?:\.\d+)?"
71
+ if re.fullmatch(rf"\s*{number}\s*", value):
72
+ result = float(value)
73
+ else:
74
+ part = rf"({number})\s*(ms|s|m|h|d)"
75
+ if not re.fullmatch(rf"\s*(?:{part}\s*)+", value):
76
+ raise ValueError("duration must be seconds or use ms, s, m, h, d (for example 4m30s)")
77
+ result = sum(float(n) * {"ms": .001, "s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
78
+ for n, unit in re.findall(part, value))
74
79
  if not math.isfinite(result) or result <= 0:
75
80
  raise ValueError("duration must be finite and positive")
76
81
  return result
@@ -86,10 +91,20 @@ class WaitRequest(Model):
86
91
  mode: Literal["any", "all", "quorum"] = "all"
87
92
  quorum: int | None = Field(default=None, gt=0)
88
93
  wake_on: list[Target] = Field(default_factory=list)
89
- interval: float = Field(default=1, ge=.05, allow_inf_nan=False)
94
+ interval: float | str = Field(default=1, description="Seconds between probes, as a number or duration such as 3s; minimum 0.05.")
90
95
  deadline: datetime | None = Field(default=None, description="Absolute deadline; survives a caller retry. Overrides timeout.")
91
96
  details: bool = Field(default=False, description="Include raw probe observations for troubleshooting.")
92
97
 
98
+ @field_validator("interval", mode="before")
99
+ @classmethod
100
+ def parse_interval(cls, value):
101
+ if not isinstance(value, (str, int, float)) or isinstance(value, bool):
102
+ raise ValueError("interval must be seconds or a duration string")
103
+ value = seconds(value) if isinstance(value, str) else float(value)
104
+ if not math.isfinite(value) or value < .05:
105
+ raise ValueError("interval must be finite and at least 0.05 seconds")
106
+ return value
107
+
93
108
  @property
94
109
  def conditions(self):
95
110
  provider = self.provider or "codex"
@@ -41,9 +41,9 @@ return early independently of that threshold.
41
41
  | `mode` | `all` (default), `any`, or `quorum` |
42
42
  | `quorum` | Required positive count for `quorum` mode only; no greater than the number of agents and targets |
43
43
  | `wake_on` | Conditions that interrupt the wait; default `[]` |
44
- | `timeout` | String containing positive seconds or a duration such as `30s`, `10m`, `2h`, `1d`; default `10m`; `ms` also supported |
44
+ | `timeout` | String containing positive seconds or a duration such as `30s`, `4m30s`, `2h`, `1d`; default `10m`; `ms` also supported |
45
45
  | `deadline` | Timezone-qualified timestamp overriding `timeout`; continuation preserves it |
46
- | `interval` | Seconds between probes; default `1`, minimum `0.05`; increase for remote checks |
46
+ | `interval` | Seconds between probes, as a number or duration such as `3s`; default `1`, minimum `0.05`; increase for remote checks |
47
47
  | `details` | Include raw observations; default `false` |
48
48
 
49
49
  Unknown fields and duplicate main targets are rejected. Every condition accepts
@@ -100,11 +100,22 @@ scope. The Codex CLI fills the session from `CODEX_THREAD_ID` when available.
100
100
  Cursor stop hooks sometimes lack enough identity: use a unique outcome signal
101
101
  for indistinguishable concurrent assignments.
102
102
 
103
+ An agent ID with no lifecycle observation gets five seconds for hook delivery,
104
+ then becomes `unobserved`. If the required target count cannot be reached without
105
+ it, or it is a wake condition, the wait returns `error` with no continuation.
106
+ `any` and quorum waits continue when other targets can still satisfy them.
107
+ Check the ID, hook trust, and shared database, or use native waiting. A shorter request can
108
+ still time out first. This is not evidence that the worker failed or finished.
109
+ Codex task paths can remain pending while a worker in the specified parent is
110
+ running, because their aliases are learned at stop. Already observed workers
111
+ can run for the full requested deadline.
112
+
103
113
  ### Other MCP tools
104
114
 
105
115
  `list_agents(provider, session?)` returns `agents` and `limit` (50) for recent
106
116
  hook-observed workers. Use it for discovery or troubleshooting, not before every
107
- wait. `provider` is required: `codex`, `claude`, or `cursor`.
117
+ wait. `provider` is required: `codex`, `claude`, or `cursor`. The `health` field
118
+ reports the shared database, observation count, and latest observation time.
108
119
 
109
120
  `signal(key, state="ready", data=null)` publishes an event and returns its `seq`
110
121
  cursor. The key and state must be nonempty strings; optional data is a JSON object.
@@ -145,8 +156,8 @@ continuation waits for all remaining work.
145
156
 
146
157
  A stopped worker response does not prove its assignment succeeded. Read its
147
158
  report; another host hook may continue that worker. For a resumed worker, use
148
- an `agent` target with `after` set to its last returned cursor. Unknown workers
149
- stay pending.
159
+ an `agent` target with `after` set to its last returned cursor. Unobserved IDs
160
+ return the setup diagnostic described above; they never count as completed.
150
161
 
151
162
  For a new response from a resumed worker:
152
163
 
@@ -166,11 +177,16 @@ Setup configures a per-server MCP timeout of 24 hours plus 30 seconds for Codex
166
177
  and Claude Code. Change the ceiling with `setup --max-wait 3d`. Each request
167
178
  still has its own `timeout` or timezone-qualified `deadline`. An absolute
168
179
  deadline survives continuation; a timed-out continuation remains expired.
180
+ Choose the actual task deadline rather than repeatedly issuing short waits.
181
+ Use `wake_on` for blockers that should interrupt it.
169
182
 
170
183
  In Codex code mode, start `functions.exec` with
171
184
  `// @exec: {"yield_time_ms":60000}`. If it yields, resume that cell with
172
185
  `functions.wait` and `yield_time_ms: 60000`. This avoids unnecessary model
173
186
  turns collecting an unfinished tool call.
187
+ The wrapper still requires model interactions when it yields; superwait cannot
188
+ remove the host's execution-cell limit. Prefer a direct MCP call when exposed.
189
+ Native terminal/process handles still require the native collection tool.
174
190
 
175
191
  For long Cursor waits, or waits beyond a host's configured MCP timeout, run the
176
192
  same request through the host's background terminal:
@@ -197,6 +213,7 @@ Global options go before the command: `--db PATH` selects the shared store;
197
213
  | `wait` | `--request FILE` (default `-` reads stdin); `--timeout DURATION` overrides duration and clears a supplied deadline; `--output FILE` saves the result |
198
214
  | `signal KEY` | `--state STATE` (default `ready`); `--data JSON` (default `{}`) |
199
215
  | `agents PROVIDER` | Optional `--session SESSION`; lists up to 50 recent workers |
216
+ | `doctor PROVIDER` | Optional `--session SESSION`; shows the database, package version, lifecycle-event count, and latest event time; exit 1 if none have been observed |
200
217
  | `setup PROVIDER` | Host-wide by default; `--project PATH` installs for one repository; `--max-wait DURATION` defaults to `24h` |
201
218
  | `serve` | Runs the MCP server over stdio |
202
219
  | `hook PROVIDER` | Lifecycle recorder used by setup; reads the host's JSON event from stdin |
@@ -206,6 +223,24 @@ Setup installs `SKILL.md` and this reference together, preserving unrelated
206
223
  host settings and saving originals of changed files as `.superwait-backup`.
207
224
  Restart the host and review its MCP/hooks trust prompts after setup.
208
225
 
226
+ ### Verify agent recording
227
+
228
+ In Codex, open `/hooks` and review/trust the superwait hooks. Spawning a new
229
+ worker must produce observations in the same database the MCP server reads:
230
+
231
+ ```sh
232
+ superwait doctor codex
233
+ superwait agents codex
234
+ ```
235
+
236
+ `doctor` reports `no_observations` for an empty or wrong-scoped store. `observed`
237
+ means events exist, not that the currently running host loaded the latest setup.
238
+ Check `last_event_at` (Unix seconds) and a new worker's final report. Existing
239
+ sessions may retain their old hooks until restarted or resumed with fresh config.
240
+ File, HTTP, command and signal waits do not require agent hooks. Setup never
241
+ approves hooks or bypasses host trust. If the MCP tool is absent, use the CLI
242
+ command installed at the end of this reference, then reload the host.
243
+
209
244
  ## Local state
210
245
 
211
246
  Hooks and waiters share a local SQLite database, defaulting to
@@ -16,7 +16,8 @@ def serve(store: Store, default_provider="codex"):
16
16
  "Codex task paths require session from the SessionStart context to avoid matching another conversation. "
17
17
  "Results give ready reports, pending work, triggered conditions, and a copyable continue_wait request. "
18
18
  "list_agents is for discovery or troubleshooting; a stopped response is not proof its task passed. "
19
- "Unknown agents remain pending. signal reports checkpoints or blockers explicitly. "
19
+ "Unobserved required agents return an actionable error after a short hook-delivery grace period. "
20
+ "Use one realistic deadline, not repeated short waits. signal reports checkpoints or blockers explicitly. "
20
21
  "For a wait longer than the host's MCP timeout, use the superwait CLI in its background terminal. "
21
22
  "Cancelling a wait never stops the agents it observes."
22
23
  ))
@@ -29,7 +30,8 @@ def serve(store: Store, default_provider="codex"):
29
30
  A target is agent, signal, file, http, or command. Command probes execute
30
31
  repeatedly without a shell: use observational checks. File changed uses
31
32
  the state at call entry. timeout accepts 30s, 10m, 2h, 1d. An absolute
32
- deadline overrides timeout and preserves the deadline on retries.
33
+ deadline overrides timeout and preserves the deadline on retries. Use a
34
+ realistic task deadline rather than repeating 30-60 second requests.
33
35
  Returns matched/interrupted/timed_out/error, ready reports, pending work,
34
36
  and triggered conditions. Pass continue_wait back as request to continue
35
37
  remaining work with the same deadline. details=true adds raw observations.
@@ -48,7 +50,7 @@ def serve(store: Store, default_provider="codex"):
48
50
  Specify session when known. Empty means no hooks have observed these
49
51
  agents, not that they finished. after=seq waits for a later observation.
50
52
  """
51
- return {"agents": store.agents(provider, session), "limit": 50}
53
+ return {"agents": store.agents(provider, session), "limit": 50, "health": store.health(provider, session)}
52
54
 
53
55
  @server.tool()
54
56
  def signal(key: str, state: str = "ready", data: dict | None = None) -> dict[str, Any]:
@@ -117,4 +117,6 @@ def configure(provider, project=None, db=None, max_wait="24h"):
117
117
  + shlex.join(prefix) + " wait --request wait.json\n```\n")
118
118
  return {"scope": "user" if user_scope else "project",
119
119
  "written": written, "database": str(db), "max_wait": max_wait,
120
- "next": "Restart the host and review its MCP/hooks trust prompts. Cursor: use the CLI for waits beyond its tool timeout."}
120
+ "next": "Restart the host and review its MCP/hooks trust prompts (/hooks in Codex). "
121
+ f"After spawning a worker, run superwait doctor {provider} to verify lifecycle observations. "
122
+ "Cursor: use the CLI for waits beyond its tool timeout."}
@@ -111,3 +111,16 @@ class Store:
111
111
  def prune(self, days=30):
112
112
  with self.connect() as db:
113
113
  return db.execute("DELETE FROM events WHERE at < ?", (time.time() - days * 86400,)).rowcount
114
+
115
+ def health(self, provider, session=None):
116
+ query = "SELECT COUNT(*) AS agent_events, MAX(at) AS last_event_at FROM events WHERE kind='agent' AND provider=?"
117
+ args = [provider]
118
+ if session is not None:
119
+ query += " AND session=?"
120
+ args.append(session)
121
+ with self.connect() as db:
122
+ result = dict(db.execute(query, args).fetchone())
123
+ return {"status": "observed" if result["agent_events"] else "no_observations",
124
+ "provider": provider, "session": session, "database": str(self.path), **result,
125
+ "next": "Verify a newly spawned worker appears and its final report arrives. "
126
+ "If not, review hook trust (/hooks in Codex), reload the host, and check that hooks and MCP use this database."}
@@ -0,0 +1,123 @@
1
+ import asyncio
2
+ import json
3
+
4
+ import pytest
5
+ from pydantic import ValidationError
6
+
7
+ from superwait import engine
8
+ from superwait.cli import main
9
+ from superwait.engine import wait_for
10
+ from superwait.hooks import record_hook
11
+ from superwait.models import WaitRequest, seconds
12
+ from superwait.store import Store
13
+
14
+
15
+ @pytest.mark.parametrize("value,expected", [("4m30s", 270), ("1h 2m 3.5s", 3723.5),
16
+ ("200ms", .2), (" 1.5 ", 1.5), ("0m1s", 1)])
17
+ def test_compound_durations(value, expected):
18
+ assert seconds(value) == expected
19
+
20
+
21
+ @pytest.mark.parametrize("value", ["", "0s", "-1s", "1m nope", "1m30", "nan", "1e3s", "1..5s"])
22
+ def test_duration_rejects_malformed_or_nonpositive_input(value):
23
+ with pytest.raises(ValueError):
24
+ seconds(value)
25
+
26
+
27
+ @pytest.mark.parametrize("interval", [3, "3s", "3000ms"])
28
+ def test_interval_duration_uses_shared_request_schema(interval):
29
+ request = WaitRequest(agents=["worker"], timeout="4m30s", interval=interval)
30
+ assert request.interval == 3
31
+ assert request.duration() == 270
32
+ assert WaitRequest.model_validate_json(request.model_dump_json()).interval == 3
33
+
34
+
35
+ @pytest.mark.parametrize("interval", [None, True, {}, "0s", "1ms", float("inf"), float("nan")])
36
+ def test_interval_rejects_invalid_values(interval):
37
+ with pytest.raises(ValidationError):
38
+ WaitRequest(agents=["worker"], interval=interval)
39
+
40
+
41
+ async def test_missing_agent_errors_without_spending_the_whole_deadline(tmp_path, monkeypatch):
42
+ monkeypatch.setattr(engine, "AGENT_OBSERVATION_GRACE", .1)
43
+ result = await asyncio.wait_for(wait_for(WaitRequest(agents=["missing"], timeout="1h", interval=30),
44
+ Store(tmp_path / "db")), 1)
45
+ assert result["status"] == "error"
46
+ assert result["pending"][0]["state"] == "unobserved"
47
+ assert "doctor" in result["pending"][0]["error"]
48
+ assert result["continue_wait"] is None
49
+
50
+
51
+ async def test_late_hook_recovers_during_grace_and_observed_worker_can_run_longer(tmp_path, monkeypatch):
52
+ monkeypatch.setattr(engine, "AGENT_OBSERVATION_GRACE", .15)
53
+ store = Store(tmp_path / "db")
54
+ waiting = asyncio.create_task(wait_for(WaitRequest(agents=["worker"], timeout="2s", interval=.05), store))
55
+ await asyncio.sleep(.06)
56
+ store.emit("agent", "worker", "running", provider="codex", session="parent")
57
+ await asyncio.sleep(.2)
58
+ assert not waiting.done()
59
+ store.emit("agent", "worker", "stopped", provider="codex", session="parent", data={"summary":"finished"})
60
+ assert (await waiting)["ready"][0]["result"] == "finished"
61
+
62
+
63
+ async def test_task_path_waits_for_stop_mapping_while_parent_has_running_worker(tmp_path, monkeypatch):
64
+ monkeypatch.setattr(engine, "AGENT_OBSERVATION_GRACE", .1)
65
+ store = Store(tmp_path / "db")
66
+ store.emit("agent", "uuid", "running", provider="codex", session="parent")
67
+ waiting = asyncio.create_task(wait_for(WaitRequest(agents=["/root/reviewer"], session="parent",
68
+ timeout="2s", interval=.05), store))
69
+ await asyncio.sleep(.2)
70
+ assert not waiting.done()
71
+ transcript = tmp_path / "worker.jsonl"
72
+ transcript.write_text(json.dumps({"type":"session_meta","payload":{"id":"uuid",
73
+ "source":{"subagent":{"thread_spawn":{"parent_thread_id":"parent","agent_path":"/root/reviewer"}}}}})+"\n")
74
+ record_hook("codex", {"hook_event_name":"SubagentStop", "session_id":"parent", "agent_id":"uuid",
75
+ "agent_transcript_path":str(transcript),"last_assistant_message":"done"}, store)
76
+ assert (await waiting)["ready"][0]["result"] == "done"
77
+
78
+
79
+ async def test_other_parent_cannot_keep_unknown_path_wait_alive(tmp_path, monkeypatch):
80
+ monkeypatch.setattr(engine, "AGENT_OBSERVATION_GRACE", .1)
81
+ store = Store(tmp_path / "db")
82
+ store.emit("agent", "uuid", "running", provider="codex", session="other")
83
+ result = await wait_for(WaitRequest(agents=["/root/reviewer"], session="parent", timeout="2s", interval=.05), store)
84
+ assert result["status"] == "error"
85
+
86
+
87
+ async def test_unknown_agent_does_not_override_ready_quorum_or_wake(tmp_path):
88
+ store = Store(tmp_path / "db")
89
+ store.emit("signal", "ready", "ready")
90
+ for request in [WaitRequest(agents=["unknown"], targets=[{"kind":"signal","key":"ready"}], mode="any"),
91
+ WaitRequest(agents=["unknown"], wake_on=[{"kind":"signal","key":"ready"}])]:
92
+ assert (await wait_for(request, store))["status"] in ("matched", "interrupted")
93
+
94
+
95
+ @pytest.mark.parametrize("mode,quorum", [("any", None), ("quorum", 2)])
96
+ async def test_unobserved_optional_agent_does_not_abort_pending_threshold(tmp_path, monkeypatch, mode, quorum):
97
+ monkeypatch.setattr(engine, "AGENT_OBSERVATION_GRACE", .1)
98
+ store = Store(tmp_path / "db")
99
+ store.emit("signal", "first", "ready")
100
+ keys = ["later"] if mode == "any" else ["first", "later"]
101
+ waiting = asyncio.create_task(wait_for(WaitRequest(agents=["unknown"],
102
+ targets=[{"kind": "signal", "key": key} for key in keys], mode=mode, quorum=quorum,
103
+ timeout="2s", interval=.05), store))
104
+ await asyncio.sleep(.2)
105
+ assert not waiting.done()
106
+ store.emit("signal", "later", "ready")
107
+ result = await waiting
108
+ assert result["status"] == "matched"
109
+ assert result["pending"][0]["state"] == "unobserved"
110
+
111
+
112
+ def test_doctor_distinguishes_missing_scoped_observations(tmp_path, monkeypatch, capsys):
113
+ db = tmp_path / "db"
114
+ store = Store(db)
115
+ for expected in (1, 0):
116
+ monkeypatch.setattr("sys.argv", ["superwait", "--db", str(db), "doctor", "codex", "--session", "parent"])
117
+ with pytest.raises(SystemExit) as exit:
118
+ main()
119
+ assert exit.value.code == expected
120
+ result = json.loads(capsys.readouterr().out)
121
+ assert result["agent_events"] == (0 if expected else 1)
122
+ store.emit("agent", "worker", "running", provider="codex", session="parent")
123
+ assert store.health("codex", "other")["status"] == "no_observations"
@@ -95,3 +95,17 @@ async def test_mcp_cancellation_reaps_its_command_probe(tmp_path):
95
95
  except ProcessLookupError:
96
96
  break
97
97
  await asyncio.sleep(.02)
98
+
99
+
100
+ async def test_mcp_duration_strings_and_missing_hook_diagnostic(tmp_path):
101
+ params = StdioServerParameters(command=sys.executable, args=["-m", "superwait", "--db", str(tmp_path / "db"), "serve"], env=dict(os.environ))
102
+ async with Client(params) as client:
103
+ health = (await client.call_tool("list_agents", {"provider": "codex"})).structured_content["health"]
104
+ assert health["status"] == "no_observations"
105
+ assert health["agent_events"] == 0
106
+ result = await asyncio.wait_for(client.call_tool("wait_for", {"request": {
107
+ "agents": ["missing"], "timeout": "1m30s", "interval": "10s"}}), 8)
108
+ assert not result.is_error
109
+ assert result.structured_content["status"] == "error"
110
+ assert result.structured_content["pending"][0]["state"] == "unobserved"
111
+ assert result.structured_content["continue_wait"] is None
@@ -746,7 +746,7 @@ wheels = [
746
746
 
747
747
  [[package]]
748
748
  name = "superwait"
749
- version = "0.3.0"
749
+ version = "0.3.1"
750
750
  source = { editable = "." }
751
751
  dependencies = [
752
752
  { name = "httpx2" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes