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.
- {superwait-0.3.0 → superwait-0.3.1}/PKG-INFO +4 -1
- {superwait-0.3.0 → superwait-0.3.1}/README.md +3 -0
- {superwait-0.3.0 → superwait-0.3.1}/docs/hosts.md +23 -0
- {superwait-0.3.0 → superwait-0.3.1}/pyproject.toml +1 -1
- {superwait-0.3.0 → superwait-0.3.1}/src/superwait/SKILL.md +7 -3
- {superwait-0.3.0 → superwait-0.3.1}/src/superwait/cli.py +7 -0
- {superwait-0.3.0 → superwait-0.3.1}/src/superwait/engine.py +27 -3
- {superwait-0.3.0 → superwait-0.3.1}/src/superwait/models.py +21 -6
- {superwait-0.3.0 → superwait-0.3.1}/src/superwait/references/api.md +40 -5
- {superwait-0.3.0 → superwait-0.3.1}/src/superwait/server.py +5 -3
- {superwait-0.3.0 → superwait-0.3.1}/src/superwait/setup.py +3 -1
- {superwait-0.3.0 → superwait-0.3.1}/src/superwait/store.py +13 -0
- superwait-0.3.1/tests/test_observations.py +123 -0
- {superwait-0.3.0 → superwait-0.3.1}/tests/test_transport.py +14 -0
- {superwait-0.3.0 → superwait-0.3.1}/uv.lock +1 -1
- {superwait-0.3.0 → superwait-0.3.1}/.gitignore +0 -0
- {superwait-0.3.0 → superwait-0.3.1}/CONTRIBUTING.md +0 -0
- {superwait-0.3.0 → superwait-0.3.1}/LICENSE +0 -0
- {superwait-0.3.0 → superwait-0.3.1}/docs/assets/superwait-banner.png +0 -0
- {superwait-0.3.0 → superwait-0.3.1}/docs/reference.md +0 -0
- {superwait-0.3.0 → superwait-0.3.1}/docs/savings.md +0 -0
- {superwait-0.3.0 → superwait-0.3.1}/src/superwait/__init__.py +0 -0
- {superwait-0.3.0 → superwait-0.3.1}/src/superwait/__main__.py +0 -0
- {superwait-0.3.0 → superwait-0.3.1}/src/superwait/hooks.py +0 -0
- {superwait-0.3.0 → superwait-0.3.1}/tests/test_ergonomics.py +0 -0
- {superwait-0.3.0 → superwait-0.3.1}/tests/test_hooks_setup.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
9
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
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,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.
|
|
149
|
-
|
|
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
|
-
"
|
|
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,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
|
|
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
|