superwait 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {superwait-0.3.0 → superwait-0.3.2}/PKG-INFO +4 -1
  2. {superwait-0.3.0 → superwait-0.3.2}/README.md +3 -0
  3. {superwait-0.3.0 → superwait-0.3.2}/docs/hosts.md +23 -0
  4. {superwait-0.3.0 → superwait-0.3.2}/pyproject.toml +1 -1
  5. superwait-0.3.2/src/superwait/SKILL.md +31 -0
  6. {superwait-0.3.0 → superwait-0.3.2}/src/superwait/cli.py +7 -0
  7. {superwait-0.3.0 → superwait-0.3.2}/src/superwait/engine.py +34 -4
  8. {superwait-0.3.0 → superwait-0.3.2}/src/superwait/models.py +21 -6
  9. {superwait-0.3.0 → superwait-0.3.2}/src/superwait/references/api.md +70 -7
  10. {superwait-0.3.0 → superwait-0.3.2}/src/superwait/server.py +5 -3
  11. {superwait-0.3.0 → superwait-0.3.2}/src/superwait/setup.py +3 -1
  12. {superwait-0.3.0 → superwait-0.3.2}/src/superwait/store.py +13 -0
  13. superwait-0.3.2/tests/test_observations.py +156 -0
  14. {superwait-0.3.0 → superwait-0.3.2}/tests/test_transport.py +14 -0
  15. {superwait-0.3.0 → superwait-0.3.2}/uv.lock +1 -1
  16. superwait-0.3.0/src/superwait/SKILL.md +0 -26
  17. {superwait-0.3.0 → superwait-0.3.2}/.gitignore +0 -0
  18. {superwait-0.3.0 → superwait-0.3.2}/CONTRIBUTING.md +0 -0
  19. {superwait-0.3.0 → superwait-0.3.2}/LICENSE +0 -0
  20. {superwait-0.3.0 → superwait-0.3.2}/docs/assets/superwait-banner.png +0 -0
  21. {superwait-0.3.0 → superwait-0.3.2}/docs/reference.md +0 -0
  22. {superwait-0.3.0 → superwait-0.3.2}/docs/savings.md +0 -0
  23. {superwait-0.3.0 → superwait-0.3.2}/src/superwait/__init__.py +0 -0
  24. {superwait-0.3.0 → superwait-0.3.2}/src/superwait/__main__.py +0 -0
  25. {superwait-0.3.0 → superwait-0.3.2}/src/superwait/hooks.py +0 -0
  26. {superwait-0.3.0 → superwait-0.3.2}/tests/test_ergonomics.py +0 -0
  27. {superwait-0.3.0 → superwait-0.3.2}/tests/test_hooks_setup.py +0 -0
  28. {superwait-0.3.0 → superwait-0.3.2}/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.2
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.2"
8
8
  description = "Conditional waiting for coding agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,31 @@
1
+ ---
2
+ name: superwait
3
+ description: Prefer superwait for waiting on agents, files, services, or signals.
4
+ ---
5
+
6
+ # superwait
7
+
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
+
11
+ Prefer a direct `wait_for` MCP call (avoids code-mode wrapper wakeups):
12
+
13
+ ```json
14
+ {"request":{"agents":["worker-a-id","worker-b-id"],"timeout":"10m"}}
15
+ ```
16
+
17
+ Use handles returned by the host. For Codex `/root/...` paths, add `session`
18
+ from startup context. The default waits for all agents. Read the `ready`
19
+ reports; use `continue_wait` as the next `request` for remaining work.
20
+ For a resumed worker, use an agent target with `after` set to its last cursor.
21
+ Choose the real task deadline, not repeated 30–60-second timeouts. An expired
22
+ continuation stays expired. On an agent timeout, check native status before
23
+ extending: recorded state can be stale. Native process handles need native tools.
24
+
25
+ If only Codex code mode is available, start `functions.exec` with
26
+ `// @exec: {"yield_time_ms":60000}`. If it yields, resume that cell with
27
+ `functions.wait` and `yield_time_ms: 60000`. Print `result.structuredContent`
28
+ once, not the entire MCP envelope.
29
+
30
+ For target syntax, direct-tool configuration, quorum, early wakeups, or the CLI
31
+ fallback, read the [local API reference](references/api.md) when needed.
@@ -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:
@@ -161,7 +182,7 @@ async def wait_for(request: WaitRequest, store: Store, progress=None):
161
182
  if e := r.get("event"):
162
183
  c["cursor"] = e["seq"]
163
184
  if isinstance(t, Agent):
164
- c.update(id=e["key"], handle=t.id, session=e["session"])
185
+ c.update(id=e["key"], handle=t.id, session=e["session"], last_observed_at=e["at"])
165
186
  if r["matched"]:
166
187
  data = e["data"]
167
188
  field = "report" if data.get("report") else "summary"
@@ -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
@@ -191,6 +212,12 @@ async def wait_for(request: WaitRequest, store: Store, progress=None):
191
212
  ready = [card(t, r) for t, r, _ in main if r["matched"]]
192
213
  pending = [card(t, r) for t, r, _ in main if not r["matched"]]
193
214
  triggered = [card(t, r) for t, r, _ in wake if r["matched"]]
215
+ if status == "timed_out":
216
+ for c in pending:
217
+ if c["kind"] == "agent" and "cursor" in c:
218
+ c["hint"] = ("This is the last recorded lifecycle observation, not live worker status. "
219
+ "A stop or interruption may have gone unrecorded. Check the worker with "
220
+ "the host's native tools before extending the deadline.")
194
221
  reason = {
195
222
  "matched": f"{len(ready)} of {len(primary)} targets ready; {needed} required.",
196
223
  "interrupted": "Wake condition matched: " + ", ".join(str(c["label"]) for c in triggered),
@@ -237,6 +264,9 @@ async def wait_for(request: WaitRequest, store: Store, progress=None):
237
264
  return outcome("interrupted")
238
265
  if sum(r["matched"] for r in results[:len(primary)]) >= needed:
239
266
  return outcome("matched")
267
+ observable = sum(r.get("state") != "unobserved" for r in results[:len(primary)])
268
+ if observable < needed or any(r.get("state") == "unobserved" for r in results[len(primary):]):
269
+ return outcome("error", "An agent has no lifecycle observations. Check its handle and hook setup, or use native waiting.")
240
270
  finally:
241
271
  for worker in workers:
242
272
  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,29 @@ 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
+
113
+ Hook observations are not a live worker-status feed. In Codex, interruption or
114
+ a missing stop hook can leave a worker recorded as `running`; follow-up turns
115
+ can leave the previous `stopped` observation in place until the next stop.
116
+ On an agent timeout, inspect the worker with native tools before extending the
117
+ deadline. Superwait does not infer completion or failure from elapsed time.
118
+ Use `after` for follow-up responses, as shown below.
119
+
103
120
  ### Other MCP tools
104
121
 
105
122
  `list_agents(provider, session?)` returns `agents` and `limit` (50) for recent
106
123
  hook-observed workers. Use it for discovery or troubleshooting, not before every
107
- wait. `provider` is required: `codex`, `claude`, or `cursor`.
124
+ wait. `provider` is required: `codex`, `claude`, or `cursor`. The `health` field
125
+ reports the shared database, observation count, and latest observation time.
108
126
 
109
127
  `signal(key, state="ready", data=null)` publishes an event and returns its `seq`
110
128
  cursor. The key and state must be nonempty strings; optional data is a JSON object.
@@ -133,7 +151,9 @@ The result includes:
133
151
  - `continue_wait`: a ready-to-use request for remaining work, or `null`
134
152
 
135
153
  Reports include the observed state, available worker result, and event cursor
136
- where applicable. `details: true` adds `checks` and raw `targets` / `wake_on`
154
+ where applicable. Agent cards with an observation include `last_observed_at`
155
+ (Unix seconds); a timed-out pending agent also includes a native-status check
156
+ hint. `details: true` adds `checks` and raw `targets` / `wake_on`
137
157
  observations. An early wake condition wins if it and the main threshold match
138
158
  in the same check.
139
159
 
@@ -145,8 +165,8 @@ continuation waits for all remaining work.
145
165
 
146
166
  A stopped worker response does not prove its assignment succeeded. Read its
147
167
  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.
168
+ an `agent` target with `after` set to its last returned cursor. Unobserved IDs
169
+ return the setup diagnostic described above; they never count as completed.
150
170
 
151
171
  For a new response from a resumed worker:
152
172
 
@@ -166,11 +186,35 @@ Setup configures a per-server MCP timeout of 24 hours plus 30 seconds for Codex
166
186
  and Claude Code. Change the ceiling with `setup --max-wait 3d`. Each request
167
187
  still has its own `timeout` or timezone-qualified `deadline`. An absolute
168
188
  deadline survives continuation; a timed-out continuation remains expired.
189
+ Choose the actual task deadline rather than repeatedly issuing short waits.
190
+ Use `wake_on` for blockers that should interrupt it.
169
191
 
170
- In Codex code mode, start `functions.exec` with
192
+ Prefer a direct MCP call when exposed. In Codex code mode, start `functions.exec` with
171
193
  `// @exec: {"yield_time_ms":60000}`. If it yields, resume that cell with
172
194
  `functions.wait` and `yield_time_ms: 60000`. This avoids unnecessary model
173
195
  turns collecting an unfinished tool call.
196
+ The wrapper still requires model interactions when it yields; superwait cannot
197
+ remove the host's execution-cell limit. When printing a nested MCP result, print
198
+ `result.structuredContent` once; the full envelope repeats it as text too.
199
+ Native terminal/process handles still require the native collection tool.
200
+
201
+ ### Direct tools in Codex code mode
202
+
203
+ Codex 0.153.1 supports exposing selected namespaces directly. If code mode is
204
+ enabled, add `mcp__superwait` to this list in your Codex configuration:
205
+
206
+ ```toml
207
+ [features.code_mode]
208
+ direct_only_tool_namespaces = ["mcp__superwait"]
209
+ ```
210
+
211
+ Edit an existing table/list in place and preserve any other namespaces; do not
212
+ add a duplicate TOML table. Start a fresh session and verify `wait_for` is a
213
+ direct tool. This removes execution-cell resume calls for that wait, not MCP
214
+ timeouts or turns needed for other work. Setup does not change this optional,
215
+ Codex-specific setting.
216
+
217
+ ### CLI fallback
174
218
 
175
219
  For long Cursor waits, or waits beyond a host's configured MCP timeout, run the
176
220
  same request through the host's background terminal:
@@ -197,6 +241,7 @@ Global options go before the command: `--db PATH` selects the shared store;
197
241
  | `wait` | `--request FILE` (default `-` reads stdin); `--timeout DURATION` overrides duration and clears a supplied deadline; `--output FILE` saves the result |
198
242
  | `signal KEY` | `--state STATE` (default `ready`); `--data JSON` (default `{}`) |
199
243
  | `agents PROVIDER` | Optional `--session SESSION`; lists up to 50 recent workers |
244
+ | `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
245
  | `setup PROVIDER` | Host-wide by default; `--project PATH` installs for one repository; `--max-wait DURATION` defaults to `24h` |
201
246
  | `serve` | Runs the MCP server over stdio |
202
247
  | `hook PROVIDER` | Lifecycle recorder used by setup; reads the host's JSON event from stdin |
@@ -206,6 +251,24 @@ Setup installs `SKILL.md` and this reference together, preserving unrelated
206
251
  host settings and saving originals of changed files as `.superwait-backup`.
207
252
  Restart the host and review its MCP/hooks trust prompts after setup.
208
253
 
254
+ ### Verify agent recording
255
+
256
+ In Codex, open `/hooks` and review/trust the superwait hooks. Spawning a new
257
+ worker must produce observations in the same database the MCP server reads:
258
+
259
+ ```sh
260
+ superwait doctor codex
261
+ superwait agents codex
262
+ ```
263
+
264
+ `doctor` reports `no_observations` for an empty or wrong-scoped store. `observed`
265
+ means events exist, not that the currently running host loaded the latest setup.
266
+ Check `last_event_at` (Unix seconds) and a new worker's final report. Existing
267
+ sessions may retain their old hooks until restarted or resumed with fresh config.
268
+ File, HTTP, command and signal waits do not require agent hooks. Setup never
269
+ approves hooks or bypasses host trust. If the MCP tool is absent, use the CLI
270
+ command installed at the end of this reference, then reload the host.
271
+
209
272
  ## Local state
210
273
 
211
274
  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,156 @@
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
+ @pytest.mark.parametrize("state,after", [("running", 0), ("stopped", 1)])
88
+ async def test_agent_timeout_exposes_observation_age_and_native_fallback(tmp_path, state, after):
89
+ store = Store(tmp_path / "db")
90
+ cursor = store.emit("agent", "worker", state, provider="codex", session="parent")
91
+ observed_at = store.latest("agent", "worker", provider="codex")["at"]
92
+ result = await wait_for(WaitRequest(targets=[{"kind": "agent", "provider": "codex",
93
+ "id": "worker", "after": after}], timeout="100ms", interval=.05), store)
94
+ assert result["status"] == "timed_out"
95
+ pending = result["pending"][0]
96
+ assert pending["state"] == ("running" if state == "running" else "awaiting_new_event")
97
+ assert pending["cursor"] == cursor
98
+ assert pending["last_observed_at"] == observed_at
99
+ assert "native tools" in pending["hint"]
100
+ # A timeout must neither manufacture a terminal event nor lose its deadline.
101
+ assert store.latest("agent", "worker", provider="codex")["seq"] == cursor
102
+ assert result["continue_wait"]["deadline"] == result["deadline"]
103
+
104
+
105
+ async def test_matched_agent_and_file_timeout_do_not_get_stale_worker_hint(tmp_path):
106
+ store = Store(tmp_path / "db")
107
+ store.emit("agent", "worker", "stopped", provider="codex", session="parent", data={"summary": "done"})
108
+ result = await wait_for(WaitRequest(agents=["worker"], timeout="1s"), store)
109
+ assert result["status"] == "matched"
110
+ assert result["ready"][0]["result"] == "done"
111
+ assert "last_observed_at" in result["ready"][0]
112
+ assert "hint" not in result["ready"][0]
113
+ result = await wait_for(WaitRequest(targets=[{"kind": "file", "path": str(tmp_path / "absent")}],
114
+ timeout="100ms"), store)
115
+ assert result["status"] == "timed_out"
116
+ assert "hint" not in result["pending"][0]
117
+ assert "last_observed_at" not in result["pending"][0]
118
+
119
+
120
+ async def test_unknown_agent_does_not_override_ready_quorum_or_wake(tmp_path):
121
+ store = Store(tmp_path / "db")
122
+ store.emit("signal", "ready", "ready")
123
+ for request in [WaitRequest(agents=["unknown"], targets=[{"kind":"signal","key":"ready"}], mode="any"),
124
+ WaitRequest(agents=["unknown"], wake_on=[{"kind":"signal","key":"ready"}])]:
125
+ assert (await wait_for(request, store))["status"] in ("matched", "interrupted")
126
+
127
+
128
+ @pytest.mark.parametrize("mode,quorum", [("any", None), ("quorum", 2)])
129
+ async def test_unobserved_optional_agent_does_not_abort_pending_threshold(tmp_path, monkeypatch, mode, quorum):
130
+ monkeypatch.setattr(engine, "AGENT_OBSERVATION_GRACE", .1)
131
+ store = Store(tmp_path / "db")
132
+ store.emit("signal", "first", "ready")
133
+ keys = ["later"] if mode == "any" else ["first", "later"]
134
+ waiting = asyncio.create_task(wait_for(WaitRequest(agents=["unknown"],
135
+ targets=[{"kind": "signal", "key": key} for key in keys], mode=mode, quorum=quorum,
136
+ timeout="2s", interval=.05), store))
137
+ await asyncio.sleep(.2)
138
+ assert not waiting.done()
139
+ store.emit("signal", "later", "ready")
140
+ result = await waiting
141
+ assert result["status"] == "matched"
142
+ assert result["pending"][0]["state"] == "unobserved"
143
+
144
+
145
+ def test_doctor_distinguishes_missing_scoped_observations(tmp_path, monkeypatch, capsys):
146
+ db = tmp_path / "db"
147
+ store = Store(db)
148
+ for expected in (1, 0):
149
+ monkeypatch.setattr("sys.argv", ["superwait", "--db", str(db), "doctor", "codex", "--session", "parent"])
150
+ with pytest.raises(SystemExit) as exit:
151
+ main()
152
+ assert exit.value.code == expected
153
+ result = json.loads(capsys.readouterr().out)
154
+ assert result["agent_events"] == (0 if expected else 1)
155
+ store.emit("agent", "worker", "running", provider="codex", session="parent")
156
+ 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.2"
750
750
  source = { editable = "." }
751
751
  dependencies = [
752
752
  { name = "httpx2" },
@@ -1,26 +0,0 @@
1
- ---
2
- name: superwait
3
- description: Prefer superwait for waiting on agents, files, services, or signals.
4
- ---
5
-
6
- # superwait
7
-
8
- Prefer superwait over native wait tools or polling loops. Use native tools to
9
- spawn or steer agents.
10
-
11
- Call the `wait_for` MCP tool:
12
-
13
- ```json
14
- {"request":{"agents":["worker-a-id","worker-b-id"],"timeout":"10m"}}
15
- ```
16
-
17
- Use handles returned by the host. For Codex `/root/...` paths, add `session`
18
- from startup context. The default waits for all agents. Read the `ready`
19
- reports; use `continue_wait` as the next `request` for remaining work.
20
-
21
- In Codex code mode, start `functions.exec` with
22
- `// @exec: {"yield_time_ms":60000}`. If it yields, resume that cell with
23
- `functions.wait` and `yield_time_ms: 60000`.
24
-
25
- For other conditions, quorum, early wakeups, continuation details, or CLI usage,
26
- read the [local API reference](references/api.md) only when needed.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes