yeschef-cli 0.1.0__py3-none-any.whl

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.
yeschef/models.py ADDED
@@ -0,0 +1,431 @@
1
+ """Shared types for hub, SDK, and harness.
2
+
3
+ Task states deliberately mirror the MCP Tasks extension vocabulary (SEP-2663) so the
4
+ tool surface can be upgraded to spec-native tasks without renaming anything.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import secrets
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from enum import StrEnum
13
+
14
+ _ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz"
15
+
16
+
17
+ def now() -> float:
18
+ """Hub-assigned UTC epoch seconds. Agents never write times."""
19
+ return time.time()
20
+
21
+
22
+ def _iso(ts: float | None) -> str | None:
23
+ if not ts:
24
+ return None
25
+ import datetime as _dt
26
+
27
+ return _dt.datetime.fromtimestamp(ts, _dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
28
+
29
+
30
+ def new_id(prefix: str, length: int = 10) -> str:
31
+ body = "".join(secrets.choice(_ALPHABET) for _ in range(length))
32
+ return f"{prefix}_{body}"
33
+
34
+
35
+ class TaskState(StrEnum):
36
+ QUEUED = "queued"
37
+ CLAIMED = "claimed"
38
+ WORKING = "working"
39
+ INPUT_REQUIRED = "input_required"
40
+ COMPLETED = "completed"
41
+ FAILED = "failed"
42
+ CANCELLED = "cancelled"
43
+
44
+ @property
45
+ def terminal(self) -> bool:
46
+ return self in (TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED)
47
+
48
+ @property
49
+ def active(self) -> bool:
50
+ """Held by an agent — subject to heartbeat reclamation."""
51
+ return self in (TaskState.CLAIMED, TaskState.WORKING, TaskState.INPUT_REQUIRED)
52
+
53
+
54
+ class AgentKind(StrEnum):
55
+ WORKER = "worker"
56
+ CLAUDE = "claude"
57
+
58
+
59
+ class AgentStatus(StrEnum):
60
+ ONLINE = "online"
61
+ BUSY = "busy"
62
+ OFFLINE = "offline"
63
+
64
+
65
+ class TurnPolicy(StrEnum):
66
+ FREE = "free"
67
+ ROUND_ROBIN = "round_robin"
68
+
69
+
70
+ class ReplyWhen(StrEnum):
71
+ MENTIONED = "mentioned"
72
+ ROUND_ROBIN = "round_robin"
73
+ ALWAYS = "always"
74
+
75
+
76
+ class EventKind(StrEnum):
77
+ MESSAGE = "message"
78
+ TASK_ASSIGNED = "task_assigned"
79
+ TASK_CANCELLED = "task_cancelled"
80
+ TASK_UPDATED = "task_updated"
81
+ ROOM_INVITE = "room_invite"
82
+ FLOOR_GRANTED = "floor_granted"
83
+ ROOM_ARCHIVED = "room_archived"
84
+ SHUTDOWN = "shutdown"
85
+
86
+
87
+ class ErrorCode(StrEnum):
88
+ NOT_FOUND = "not_found"
89
+ CONFLICT = "conflict"
90
+ FORBIDDEN = "forbidden"
91
+ UNAUTHORIZED = "unauthorized"
92
+ INVALID = "invalid"
93
+ POLICY_EXCEEDED = "policy_exceeded"
94
+
95
+
96
+ class HubError(Exception):
97
+ """Error that crosses the wire as {"error": {"code", "message"}}."""
98
+
99
+ def __init__(self, code: ErrorCode, message: str, http_status: int = 400) -> None:
100
+ super().__init__(message)
101
+ self.code = code
102
+ self.message = message
103
+ self.http_status = http_status
104
+
105
+ def to_dict(self) -> dict:
106
+ return {"error": {"code": str(self.code), "message": self.message}}
107
+
108
+
109
+ def not_found(what: str) -> HubError:
110
+ return HubError(ErrorCode.NOT_FOUND, f"{what} not found", 404)
111
+
112
+
113
+ HEARTBEAT_TTL_S = 30.0
114
+ """Agent considered offline after this long without a heartbeat or open event stream."""
115
+
116
+ MAX_LONG_POLL_S = 60.0
117
+ """Cap on any explicit wait, staying clear of Claude Code's ~2 min auto-background."""
118
+
119
+ MAX_ARTIFACT_BYTES = 32 * 1024 * 1024
120
+
121
+ DEFAULT_TASK_TIMEOUT_S = 3600.0
122
+
123
+ INPUT_REQUIRED_TTL_S = 30 * 60.0
124
+ """A parked question fails the task after this long — stranded input_required tasks
125
+ sat for 41 minutes with nobody notified before this existed."""
126
+
127
+ DEFAULT_MAX_ROOM_MESSAGES = 200
128
+ """Backstop applied to any room created without a message cap.
129
+
130
+ The point is not the number — it is that no room is ever unbounded. Two agents set to
131
+ reply on every message will otherwise talk to each other until the hardware gives out.
132
+ """
133
+
134
+ DEFAULT_ROOM_IDLE_TIMEOUT_S = 24 * 3600.0
135
+ """Backstop applied to any room created without an idle timeout."""
136
+
137
+
138
+ @dataclass(slots=True)
139
+ class ToolCall:
140
+ """A model's request to run a worker-side tool.
141
+
142
+ Lives here rather than in `agent.backends` so `tools` and `agent` can both use it
143
+ without importing each other.
144
+ """
145
+
146
+ id: str
147
+ name: str
148
+ arguments: dict
149
+
150
+
151
+ @dataclass(slots=True)
152
+ class ToolResult:
153
+ call_id: str
154
+ name: str
155
+ content: str
156
+ is_error: bool = False
157
+
158
+
159
+ @dataclass(slots=True)
160
+ class RoomPolicy:
161
+ """Hub-enforced guards. Every autonomous room is bounded by construction."""
162
+
163
+ turn_policy: TurnPolicy = TurnPolicy.FREE
164
+ max_messages: int | None = None
165
+ max_total_tokens: int | None = None
166
+ idle_timeout_s: float | None = None
167
+ stop_phrase: str | None = None
168
+
169
+ def to_dict(self) -> dict:
170
+ return {
171
+ "turn_policy": str(self.turn_policy),
172
+ "max_messages": self.max_messages,
173
+ "max_total_tokens": self.max_total_tokens,
174
+ "idle_timeout_s": self.idle_timeout_s,
175
+ "stop_phrase": self.stop_phrase,
176
+ }
177
+
178
+ @classmethod
179
+ def from_dict(cls, raw: dict | None) -> RoomPolicy:
180
+ raw = raw or {}
181
+ return cls(
182
+ turn_policy=TurnPolicy(raw.get("turn_policy") or TurnPolicy.FREE),
183
+ max_messages=raw.get("max_messages"),
184
+ max_total_tokens=raw.get("max_total_tokens"),
185
+ idle_timeout_s=raw.get("idle_timeout_s"),
186
+ stop_phrase=raw.get("stop_phrase"),
187
+ )
188
+
189
+ def bounded(
190
+ self,
191
+ max_messages: int = DEFAULT_MAX_ROOM_MESSAGES,
192
+ idle_timeout_s: float = DEFAULT_ROOM_IDLE_TIMEOUT_S,
193
+ ) -> RoomPolicy:
194
+ """Return this policy with backstops filled in where the caller left gaps.
195
+
196
+ Every room goes through here, so an unbounded conversation cannot be created
197
+ by omission — only a caller who names a larger explicit limit gets one.
198
+ """
199
+ return RoomPolicy(
200
+ turn_policy=self.turn_policy,
201
+ max_messages=self.max_messages if self.max_messages is not None else max_messages,
202
+ max_total_tokens=self.max_total_tokens,
203
+ idle_timeout_s=(
204
+ self.idle_timeout_s if self.idle_timeout_s is not None else idle_timeout_s
205
+ ),
206
+ stop_phrase=self.stop_phrase,
207
+ )
208
+
209
+
210
+ @dataclass(slots=True)
211
+ class Agent:
212
+ name: str
213
+ kind: AgentKind
214
+ node: str | None = None
215
+ backend: str | None = None
216
+ tags: list[str] = field(default_factory=list)
217
+ last_seen: float = 0.0
218
+ created_at: float = 0.0
219
+
220
+ def status(self, ref: float | None = None) -> AgentStatus:
221
+ ref = ref if ref is not None else now()
222
+ if ref - self.last_seen > HEARTBEAT_TTL_S:
223
+ return AgentStatus.OFFLINE
224
+ return AgentStatus.ONLINE
225
+
226
+ def matches(self, selector: str) -> bool:
227
+ """`name` match, or `tag:value` / bare tag selector.
228
+
229
+ `a|b` is a union: any part matching means the agent matches, so tag-faithful
230
+ dispatch can still spill onto idle capacity (`tier:fast|tier:build`).
231
+ """
232
+ return any(
233
+ part == "*" or part == self.name or part in self.tags
234
+ for part in (p.strip() for p in selector.split("|"))
235
+ if part
236
+ )
237
+
238
+ def to_dict(self, ref: float | None = None) -> dict:
239
+ return {
240
+ "name": self.name,
241
+ "kind": str(self.kind),
242
+ "node": self.node,
243
+ "backend": self.backend,
244
+ "tags": list(self.tags),
245
+ "status": str(self.status(ref)),
246
+ "heartbeat_age_s": round((ref if ref is not None else now()) - self.last_seen, 1),
247
+ "last_seen": self.last_seen,
248
+ "created_at": self.created_at,
249
+ }
250
+
251
+
252
+ @dataclass(slots=True)
253
+ class Room:
254
+ id: str
255
+ topic: str
256
+ created_by: str
257
+ open: bool = False
258
+ policy: RoomPolicy = field(default_factory=RoomPolicy)
259
+ archived: bool = False
260
+ archived_reason: str | None = None
261
+ dm_key: str | None = None
262
+ floor_holder: str | None = None
263
+ created_at: float = 0.0
264
+ members: list[str] = field(default_factory=list)
265
+
266
+ def to_dict(self) -> dict:
267
+ return {
268
+ "id": self.id,
269
+ "topic": self.topic,
270
+ "created_by": self.created_by,
271
+ "open": self.open,
272
+ "is_dm": self.dm_key is not None,
273
+ "floor_holder": self.floor_holder,
274
+ "policy": self.policy.to_dict(),
275
+ "archived": self.archived,
276
+ "archived_reason": self.archived_reason,
277
+ "members": list(self.members),
278
+ "created_at": self.created_at,
279
+ }
280
+
281
+
282
+ @dataclass(slots=True)
283
+ class Message:
284
+ id: str
285
+ room_id: str
286
+ seq: int
287
+ sender: str
288
+ body: str
289
+ data: dict | None = None
290
+ reply_to: str | None = None
291
+ mentions: list[str] = field(default_factory=list)
292
+ created_at: float = 0.0
293
+
294
+ def to_dict(self) -> dict:
295
+ return {
296
+ "id": self.id,
297
+ "room_id": self.room_id,
298
+ "seq": self.seq,
299
+ "sender": self.sender,
300
+ "body": self.body,
301
+ "data": self.data,
302
+ "reply_to": self.reply_to,
303
+ "mentions": list(self.mentions),
304
+ "created_at": self.created_at,
305
+ }
306
+
307
+
308
+ @dataclass(slots=True)
309
+ class Task:
310
+ id: str
311
+ title: str
312
+ spec: str
313
+ created_by: str
314
+ state: TaskState = TaskState.QUEUED
315
+ assignee: str | None = None
316
+ selector: str | None = None
317
+ priority: int = 0
318
+ timeout_s: float = DEFAULT_TASK_TIMEOUT_S
319
+ dedupe_key: str | None = None
320
+ project: str | None = None
321
+ output_mode: str | None = None
322
+ data: str | None = None
323
+ input_required_at: float | None = None
324
+ room_id: str | None = None
325
+ progress_pct: float | None = None
326
+ progress_msg: str | None = None
327
+ result: dict | None = None
328
+ error: str | None = None
329
+ attempts: int = 0
330
+ created_at: float = 0.0
331
+ claimed_at: float | None = None
332
+ finished_at: float | None = None
333
+
334
+ def to_dict(self) -> dict:
335
+ return {
336
+ "id": self.id,
337
+ "title": self.title,
338
+ "spec": self.spec,
339
+ "created_by": self.created_by,
340
+ "state": str(self.state),
341
+ "assignee": self.assignee,
342
+ "selector": self.selector,
343
+ "project": self.project,
344
+ "output_mode": self.output_mode,
345
+ "data": self.data,
346
+ "priority": self.priority,
347
+ "timeout_s": self.timeout_s,
348
+ "room_id": self.room_id,
349
+ "progress": {"pct": self.progress_pct, "message": self.progress_msg},
350
+ "result": self.result,
351
+ "error": self.error,
352
+ "attempts": self.attempts,
353
+ "created_at": self.created_at,
354
+ "claimed_at": self.claimed_at,
355
+ "finished_at": self.finished_at,
356
+ "created_iso": _iso(self.created_at),
357
+ "claimed_iso": _iso(self.claimed_at),
358
+ "finished_iso": _iso(self.finished_at),
359
+ }
360
+
361
+ def to_summary(self) -> dict:
362
+ """Slim view for acks, waits, and listings — no spec or result bodies.
363
+
364
+ The spec is text the caller wrote and the result has its own tool; echoing
365
+ either in every reply re-bills an orchestrating model for its own words on
366
+ each poll of a long task.
367
+ """
368
+ return {
369
+ "id": self.id,
370
+ "title": self.title,
371
+ "state": str(self.state),
372
+ "assignee": self.assignee,
373
+ "created_by": self.created_by,
374
+ "project": self.project,
375
+ "priority": self.priority or None,
376
+ "room_id": self.room_id,
377
+ "progress": {"pct": self.progress_pct, "message": self.progress_msg},
378
+ "error": self.error,
379
+ "attempts": self.attempts,
380
+ "created_at": self.created_at,
381
+ "finished_at": self.finished_at,
382
+ "input_expires_at": (
383
+ round(
384
+ (self.input_required_at or self.claimed_at or self.created_at)
385
+ + INPUT_REQUIRED_TTL_S,
386
+ 1,
387
+ )
388
+ if str(self.state) == "input_required"
389
+ else None
390
+ ),
391
+ "flags": [
392
+ k
393
+ for k in (
394
+ "truncated",
395
+ "no_files",
396
+ "all_tools_failed",
397
+ "code_in_text_only",
398
+ "unverified_claims",
399
+ "tool_text_unparsed",
400
+ "echoes_spec",
401
+ "partial",
402
+ )
403
+ if (self.result or {}).get(k)
404
+ ]
405
+ or None,
406
+ "age_s": round(now() - self.created_at, 1) if self.created_at else None,
407
+ "ran_s": (
408
+ round(self.finished_at - self.claimed_at, 1)
409
+ if self.finished_at and self.claimed_at
410
+ else None
411
+ ),
412
+ "files": len((self.result or {}).get("files") or []),
413
+ }
414
+
415
+
416
+ @dataclass(slots=True)
417
+ class TaskEvent:
418
+ id: str
419
+ task_id: str
420
+ kind: str
421
+ payload: dict
422
+ created_at: float
423
+
424
+ def to_dict(self) -> dict:
425
+ return {
426
+ "id": self.id,
427
+ "task_id": self.task_id,
428
+ "kind": self.kind,
429
+ "payload": self.payload,
430
+ "created_at": self.created_at,
431
+ }
yeschef/procs.py ADDED
@@ -0,0 +1,109 @@
1
+ """Track detached hub/agent processes so one terminal can start and stop the fleet.
2
+
3
+ Best-effort process supervision: enough for `up --detach` / `join --detach` / `down`
4
+ on a workstation, not a replacement for the launchd and systemd units under
5
+ examples/deploy/ for a real deployment.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import signal
13
+ import subprocess
14
+ import sys
15
+ from dataclasses import asdict, dataclass
16
+ from pathlib import Path
17
+
18
+ from .settings import home
19
+
20
+
21
+ def _run_dir() -> Path:
22
+ path = home() / "run"
23
+ path.mkdir(parents=True, exist_ok=True)
24
+ return path
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class Proc:
29
+ label: str
30
+ pid: int
31
+ kind: str # "hub" | "agent"
32
+ log: str
33
+ command: list[str]
34
+
35
+
36
+ def _record_path(label: str) -> Path:
37
+ safe = label.replace("/", "_").replace(":", "_")
38
+ return _run_dir() / f"{safe}.json"
39
+
40
+
41
+ def spawn(label: str, kind: str, command: list[str]) -> Proc:
42
+ """Start a detached child that outlives this process, logging to a file."""
43
+ existing = get(label)
44
+ if existing and is_alive(existing.pid):
45
+ raise RuntimeError(f"'{label}' is already running (pid {existing.pid})")
46
+
47
+ log_path = _run_dir() / f"{label.replace('/', '_').replace(':', '_')}.log"
48
+ log_file = open(log_path, "ab") # noqa: SIM115 - handed to the child, closed on exit
49
+ kwargs: dict = {"stdout": log_file, "stderr": subprocess.STDOUT, "stdin": subprocess.DEVNULL}
50
+ if os.name == "nt":
51
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
52
+ else:
53
+ kwargs["start_new_session"] = True
54
+
55
+ process = subprocess.Popen(command, **kwargs) # noqa: S603 - command is built by us
56
+ log_file.close()
57
+ proc = Proc(label=label, pid=process.pid, kind=kind, log=str(log_path), command=command)
58
+ _record_path(label).write_text(json.dumps(asdict(proc), indent=2))
59
+ return proc
60
+
61
+
62
+ def is_alive(pid: int) -> bool:
63
+ if os.name == "nt":
64
+ out = subprocess.run(["tasklist", "/FI", f"PID eq {pid}"], capture_output=True, text=True)
65
+ return str(pid) in out.stdout
66
+ try:
67
+ os.kill(pid, 0)
68
+ except ProcessLookupError:
69
+ return False
70
+ except PermissionError:
71
+ return True
72
+ return True
73
+
74
+
75
+ def get(label: str) -> Proc | None:
76
+ path = _record_path(label)
77
+ if not path.exists():
78
+ return None
79
+ raw = json.loads(path.read_text())
80
+ return Proc(**raw)
81
+
82
+
83
+ def list_all() -> list[Proc]:
84
+ return [Proc(**json.loads(p.read_text())) for p in sorted(_run_dir().glob("*.json"))]
85
+
86
+
87
+ def stop(label: str) -> bool:
88
+ proc = get(label)
89
+ if proc is None:
90
+ return False
91
+ if is_alive(proc.pid):
92
+ try:
93
+ if os.name == "nt":
94
+ os.kill(proc.pid, signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined]
95
+ else:
96
+ os.kill(proc.pid, signal.SIGTERM)
97
+ except (ProcessLookupError, PermissionError):
98
+ pass
99
+ _record_path(proc.label).unlink(missing_ok=True)
100
+ return True
101
+
102
+
103
+ def stop_all() -> list[str]:
104
+ return [proc.label for proc in list_all() if stop(proc.label)]
105
+
106
+
107
+ def cli_executable() -> list[str]:
108
+ """How to re-invoke this CLI for a detached child."""
109
+ return [sys.executable, "-m", "yeschef"]