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.
- {superwait-0.3.0 → superwait-0.3.2}/PKG-INFO +4 -1
- {superwait-0.3.0 → superwait-0.3.2}/README.md +3 -0
- {superwait-0.3.0 → superwait-0.3.2}/docs/hosts.md +23 -0
- {superwait-0.3.0 → superwait-0.3.2}/pyproject.toml +1 -1
- superwait-0.3.2/src/superwait/SKILL.md +31 -0
- {superwait-0.3.0 → superwait-0.3.2}/src/superwait/cli.py +7 -0
- {superwait-0.3.0 → superwait-0.3.2}/src/superwait/engine.py +34 -4
- {superwait-0.3.0 → superwait-0.3.2}/src/superwait/models.py +21 -6
- {superwait-0.3.0 → superwait-0.3.2}/src/superwait/references/api.md +70 -7
- {superwait-0.3.0 → superwait-0.3.2}/src/superwait/server.py +5 -3
- {superwait-0.3.0 → superwait-0.3.2}/src/superwait/setup.py +3 -1
- {superwait-0.3.0 → superwait-0.3.2}/src/superwait/store.py +13 -0
- superwait-0.3.2/tests/test_observations.py +156 -0
- {superwait-0.3.0 → superwait-0.3.2}/tests/test_transport.py +14 -0
- {superwait-0.3.0 → superwait-0.3.2}/uv.lock +1 -1
- superwait-0.3.0/src/superwait/SKILL.md +0 -26
- {superwait-0.3.0 → superwait-0.3.2}/.gitignore +0 -0
- {superwait-0.3.0 → superwait-0.3.2}/CONTRIBUTING.md +0 -0
- {superwait-0.3.0 → superwait-0.3.2}/LICENSE +0 -0
- {superwait-0.3.0 → superwait-0.3.2}/docs/assets/superwait-banner.png +0 -0
- {superwait-0.3.0 → superwait-0.3.2}/docs/reference.md +0 -0
- {superwait-0.3.0 → superwait-0.3.2}/docs/savings.md +0 -0
- {superwait-0.3.0 → superwait-0.3.2}/src/superwait/__init__.py +0 -0
- {superwait-0.3.0 → superwait-0.3.2}/src/superwait/__main__.py +0 -0
- {superwait-0.3.0 → superwait-0.3.2}/src/superwait/hooks.py +0 -0
- {superwait-0.3.0 → superwait-0.3.2}/tests/test_ergonomics.py +0 -0
- {superwait-0.3.0 → superwait-0.3.2}/tests/test_hooks_setup.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
if
|
|
72
|
-
|
|
73
|
-
|
|
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,
|
|
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`, `
|
|
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
|
|
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.
|
|
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.
|
|
149
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|