ctlrm 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.
- ctlrm/__init__.py +3 -0
- ctlrm/__main__.py +307 -0
- ctlrm/communication/__init__.py +1 -0
- ctlrm/communication/terminal.py +292 -0
- ctlrm/managed/__init__.py +1 -0
- ctlrm/managed/archive.py +269 -0
- ctlrm/managed/area.py +201 -0
- ctlrm/managed/artifacts.py +164 -0
- ctlrm/managed/cli.py +276 -0
- ctlrm/managed/codex_host.py +187 -0
- ctlrm/managed/commits.py +178 -0
- ctlrm/managed/host.py +58 -0
- ctlrm/managed/project_cli.py +162 -0
- ctlrm/managed/projects.py +494 -0
- ctlrm/managed/providers.py +157 -0
- ctlrm/managed/sessions.py +691 -0
- ctlrm/managed/storage.py +232 -0
- ctlrm/managed/supervisor.py +133 -0
- ctlrm/managed/workflow_cli.py +161 -0
- ctlrm/managed/workflows.py +696 -0
- ctlrm/runtime/__init__.py +1 -0
- ctlrm/runtime/documents.py +60 -0
- ctlrm/runtime/filesystem.py +65 -0
- ctlrm/runtime/location.py +36 -0
- ctlrm/runtime/manifest.py +125 -0
- ctlrm/runtime/messages.py +69 -0
- ctlrm/runtime/participants.py +89 -0
- ctlrm/runtime/paths.py +17 -0
- ctlrm/runtime/projects.py +113 -0
- ctlrm/runtime/room.py +265 -0
- ctlrm/runtime/workflows.py +230 -0
- ctlrm/scheduler.py +23 -0
- ctlrm/version.py +11 -0
- ctlrm/web/__init__.py +1 -0
- ctlrm/web/api.py +272 -0
- ctlrm/web/app.py +74 -0
- ctlrm/web/service.py +354 -0
- ctlrm/web/static/app.css +1220 -0
- ctlrm/web/static/app.js +1319 -0
- ctlrm/web/static/index.html +129 -0
- ctlrm/web/static/vendor/addon-fit.LICENSE +19 -0
- ctlrm/web/static/vendor/addon-fit.js +2 -0
- ctlrm/web/static/vendor/xterm.LICENSE +21 -0
- ctlrm/web/static/vendor/xterm.css +285 -0
- ctlrm/web/static/vendor/xterm.js +2 -0
- ctlrm/web/templates/implement-review.yaml +44 -0
- ctlrm/web/templates/ship.yaml +81 -0
- ctlrm/web/templates/single-agent.yaml +21 -0
- ctlrm/web/terminal.py +164 -0
- ctlrm-0.1.0.dist-info/METADATA +82 -0
- ctlrm-0.1.0.dist-info/RECORD +53 -0
- ctlrm-0.1.0.dist-info/WHEEL +4 -0
- ctlrm-0.1.0.dist-info/entry_points.txt +2 -0
ctlrm/__init__.py
ADDED
ctlrm/__main__.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Typer command-line interface for filesystem-backed ctlrm rooms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated, TypeVar
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
import yaml
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
|
|
16
|
+
from ctlrm.runtime.manifest import RoleAssignment, RoomManifest
|
|
17
|
+
from ctlrm.runtime.messages import MailboxMessage
|
|
18
|
+
from ctlrm.runtime.room import RoomRuntime
|
|
19
|
+
|
|
20
|
+
from ctlrm.managed.cli import session_app, supervisor_app
|
|
21
|
+
|
|
22
|
+
from ctlrm.managed.workflow_cli import workflow_app
|
|
23
|
+
from ctlrm.managed.project_cli import project_app, job_app
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False)
|
|
26
|
+
app.add_typer(session_app, name="session")
|
|
27
|
+
app.add_typer(workflow_app, name="workflow")
|
|
28
|
+
app.add_typer(project_app, name="project")
|
|
29
|
+
app.add_typer(job_app, name="job")
|
|
30
|
+
app.add_typer(supervisor_app, name="supervisor")
|
|
31
|
+
_Result = TypeVar("_Result")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _now() -> str:
|
|
35
|
+
"""Return the current UTC time in an interoperable representation."""
|
|
36
|
+
return datetime.now(timezone.utc).isoformat()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_text(value: str | None, path: Path | None) -> str:
|
|
40
|
+
"""Resolve text supplied inline, from a file, or through standard input."""
|
|
41
|
+
if value is not None:
|
|
42
|
+
return value
|
|
43
|
+
if path is not None:
|
|
44
|
+
return path.read_text(encoding="utf-8")
|
|
45
|
+
if stdin_buffer := getattr(sys.stdin, "buffer", None):
|
|
46
|
+
return stdin_buffer.read().decode("utf-8")
|
|
47
|
+
return sys.stdin.read()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _run(action: Callable[[], _Result]) -> _Result:
|
|
51
|
+
"""Convert expected domain failures into stable command exit codes."""
|
|
52
|
+
try:
|
|
53
|
+
return action()
|
|
54
|
+
except typer.Exit:
|
|
55
|
+
raise
|
|
56
|
+
except FileExistsError as exc:
|
|
57
|
+
typer.echo(f"conflict: {exc.filename or exc}", err=True)
|
|
58
|
+
raise typer.Exit(3) from exc
|
|
59
|
+
except FileNotFoundError as exc:
|
|
60
|
+
typer.echo(f"not found: {exc.filename or exc}", err=True)
|
|
61
|
+
raise typer.Exit(4) from exc
|
|
62
|
+
except (ValidationError, ValueError, yaml.YAMLError) as exc:
|
|
63
|
+
typer.echo(f"invalid: {exc}", err=True)
|
|
64
|
+
raise typer.Exit(2) from exc
|
|
65
|
+
except (OSError, RuntimeError) as exc:
|
|
66
|
+
typer.echo(f"room error: {exc}", err=True)
|
|
67
|
+
raise typer.Exit(5) from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_assignments(values: list[str]) -> list[RoleAssignment]:
|
|
71
|
+
"""Parse repeated PARTICIPANT=ROLE assignment options."""
|
|
72
|
+
assignments = []
|
|
73
|
+
for value in values:
|
|
74
|
+
participant, separator, role = value.partition("=")
|
|
75
|
+
if not separator:
|
|
76
|
+
raise ValueError(f"assignment must be PARTICIPANT=ROLE: {value!r}")
|
|
77
|
+
assignments.append(RoleAssignment(participant=participant, role=role))
|
|
78
|
+
return assignments
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _runtime(ctx: typer.Context) -> RoomRuntime:
|
|
82
|
+
"""Return the room runtime stored by the root callback."""
|
|
83
|
+
assert isinstance(ctx.obj, RoomRuntime)
|
|
84
|
+
return ctx.obj
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.callback()
|
|
88
|
+
def root(
|
|
89
|
+
ctx: typer.Context,
|
|
90
|
+
root_path: Annotated[
|
|
91
|
+
Path | None,
|
|
92
|
+
typer.Option("--root", help="Project root containing the .ctlrm room."),
|
|
93
|
+
] = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Coordinate agents through a project-local shared directory."""
|
|
96
|
+
ctx.obj = RoomRuntime((root_path or Path.cwd()).resolve())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def serve(
|
|
101
|
+
port: Annotated[int, typer.Option(min=1024, max=65535)] = 8766,
|
|
102
|
+
data: Annotated[
|
|
103
|
+
Path | None, typer.Option(help="Override the central user data directory.")
|
|
104
|
+
] = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Open a local browser workbench for managed projects and agent terminals."""
|
|
107
|
+
import uvicorn
|
|
108
|
+
from ctlrm.managed.projects import ProjectStore
|
|
109
|
+
from ctlrm.web.app import create_app
|
|
110
|
+
|
|
111
|
+
web = create_app(ProjectStore(data), port=port)
|
|
112
|
+
typer.echo(f"Control room: http://127.0.0.1:{port}/#token={web.state.token}")
|
|
113
|
+
uvicorn.run(
|
|
114
|
+
web, host="127.0.0.1", port=port, access_log=False, ws_max_size=65536, ws_max_queue=8
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.command()
|
|
119
|
+
def init(
|
|
120
|
+
ctx: typer.Context,
|
|
121
|
+
participant_id: Annotated[str, typer.Option("--id", help="Room author's participant ID.")],
|
|
122
|
+
name: Annotated[str, typer.Option(help="Display name for the room author.")],
|
|
123
|
+
assign: Annotated[
|
|
124
|
+
list[str],
|
|
125
|
+
typer.Option(help="Role assignment as PARTICIPANT=ROLE; repeat for the full roster."),
|
|
126
|
+
],
|
|
127
|
+
prompt: Annotated[
|
|
128
|
+
str | None, typer.Option(help="Room prompt; defaults to standard input.")
|
|
129
|
+
] = None,
|
|
130
|
+
prompt_file: Annotated[
|
|
131
|
+
Path | None,
|
|
132
|
+
typer.Option("--prompt-file", help="Read the room prompt from a UTF-8 file."),
|
|
133
|
+
] = None,
|
|
134
|
+
kind: Annotated[str, typer.Option(help="Participant kind.")] = "agent",
|
|
135
|
+
provider: Annotated[str | None, typer.Option(help="Agent provider, if applicable.")] = None,
|
|
136
|
+
restart_command: Annotated[
|
|
137
|
+
str | None,
|
|
138
|
+
typer.Option("--restart-command", help="Command to restart this session; stored only."),
|
|
139
|
+
] = None,
|
|
140
|
+
capability: Annotated[list[str], typer.Option(help="Author capability; may be repeated.")] = [],
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Create the prompt and role roster, then register their author."""
|
|
143
|
+
if prompt is not None and prompt_file is not None:
|
|
144
|
+
raise typer.BadParameter("use either --prompt or --prompt-file, not both")
|
|
145
|
+
|
|
146
|
+
def initialize() -> None:
|
|
147
|
+
room = RoomManifest(
|
|
148
|
+
id=f"room-{uuid4().hex}",
|
|
149
|
+
author=participant_id,
|
|
150
|
+
created_at=_now(),
|
|
151
|
+
assignments=_parse_assignments(assign),
|
|
152
|
+
prompt=_read_text(prompt, prompt_file),
|
|
153
|
+
)
|
|
154
|
+
result = _runtime(ctx).initialize(
|
|
155
|
+
room,
|
|
156
|
+
name=name,
|
|
157
|
+
kind=kind,
|
|
158
|
+
provider=provider,
|
|
159
|
+
restart_command=restart_command,
|
|
160
|
+
capabilities=capability,
|
|
161
|
+
joined_at=_now(),
|
|
162
|
+
)
|
|
163
|
+
typer.echo(result.room_path)
|
|
164
|
+
typer.echo(result.signature_path)
|
|
165
|
+
if not result.created:
|
|
166
|
+
typer.echo("room already initialized; author registration recovered", err=True)
|
|
167
|
+
|
|
168
|
+
_run(initialize)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.command()
|
|
172
|
+
def join(
|
|
173
|
+
ctx: typer.Context,
|
|
174
|
+
participant_id: Annotated[str, typer.Option("--id", help="Assigned participant ID.")],
|
|
175
|
+
name: Annotated[str, typer.Option(help="Participant display name.")],
|
|
176
|
+
kind: Annotated[str, typer.Option(help="Participant kind.")] = "agent",
|
|
177
|
+
provider: Annotated[str | None, typer.Option(help="Agent provider, if applicable.")] = None,
|
|
178
|
+
restart_command: Annotated[
|
|
179
|
+
str | None,
|
|
180
|
+
typer.Option("--restart-command", help="Command to restart this session; stored only."),
|
|
181
|
+
] = None,
|
|
182
|
+
capability: Annotated[
|
|
183
|
+
list[str], typer.Option(help="Participant capability; may be repeated.")
|
|
184
|
+
] = [],
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Accept the role assigned in the room roster and write a signature."""
|
|
187
|
+
|
|
188
|
+
def register() -> None:
|
|
189
|
+
path = _runtime(ctx).join_participant(
|
|
190
|
+
participant_id=participant_id,
|
|
191
|
+
name=name,
|
|
192
|
+
kind=kind,
|
|
193
|
+
provider=provider,
|
|
194
|
+
capabilities=capability,
|
|
195
|
+
restart_command=restart_command,
|
|
196
|
+
joined_at=_now(),
|
|
197
|
+
)
|
|
198
|
+
typer.echo(path)
|
|
199
|
+
|
|
200
|
+
_run(register)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.command()
|
|
204
|
+
def send(
|
|
205
|
+
ctx: typer.Context,
|
|
206
|
+
sender: Annotated[str, typer.Option("--from", help="Registered sender ID.")],
|
|
207
|
+
recipient: Annotated[str, typer.Option("--to", help="Registered recipient ID.")],
|
|
208
|
+
title: Annotated[str, typer.Option(help="Short message title.")],
|
|
209
|
+
kind: Annotated[str, typer.Option(help="Message kind.")] = "message",
|
|
210
|
+
body: Annotated[
|
|
211
|
+
str | None, typer.Option(help="Message body; defaults to standard input.")
|
|
212
|
+
] = None,
|
|
213
|
+
body_file: Annotated[
|
|
214
|
+
Path | None,
|
|
215
|
+
typer.Option("--body-file", help="Read the body from a UTF-8 file."),
|
|
216
|
+
] = None,
|
|
217
|
+
file: Annotated[list[str], typer.Option(help="Related file path; may be repeated.")] = [],
|
|
218
|
+
message_id: Annotated[str | None, typer.Option("--id", help="Explicit message ID.")] = None,
|
|
219
|
+
execution_id: Annotated[str | None, typer.Option(help="Owning workflow execution ID.")] = None,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Deliver an immutable message between registered participants."""
|
|
222
|
+
if body is not None and body_file is not None:
|
|
223
|
+
raise typer.BadParameter("use either --body or --body-file, not both")
|
|
224
|
+
|
|
225
|
+
def deliver() -> None:
|
|
226
|
+
identifier = (
|
|
227
|
+
message_id or f"msg-{datetime.now(timezone.utc):%Y%m%dT%H%M%S}-{uuid4().hex[:8]}"
|
|
228
|
+
)
|
|
229
|
+
message = MailboxMessage(
|
|
230
|
+
id=identifier,
|
|
231
|
+
from_=sender,
|
|
232
|
+
to=recipient,
|
|
233
|
+
kind=kind,
|
|
234
|
+
title=title,
|
|
235
|
+
status="new",
|
|
236
|
+
created_at=_now(),
|
|
237
|
+
files=file,
|
|
238
|
+
body=_read_text(body, body_file),
|
|
239
|
+
)
|
|
240
|
+
runtime = _runtime(ctx)
|
|
241
|
+
if (runtime.root / "area.yaml").exists():
|
|
242
|
+
from ctlrm.managed.area import Area
|
|
243
|
+
from ctlrm.managed.workflows import WorkflowService
|
|
244
|
+
|
|
245
|
+
area = Area.load(runtime.project_root)
|
|
246
|
+
if area.data["mode"] == "workflow":
|
|
247
|
+
if not execution_id:
|
|
248
|
+
raise ValueError("workflow messages require --execution-id from the assignment")
|
|
249
|
+
typer.echo(WorkflowService(area).send_message(message, execution_id))
|
|
250
|
+
return
|
|
251
|
+
if execution_id:
|
|
252
|
+
raise ValueError("execution IDs require a managed workflow")
|
|
253
|
+
typer.echo(runtime.send_message(message))
|
|
254
|
+
|
|
255
|
+
_run(deliver)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@app.command()
|
|
259
|
+
def inbox(
|
|
260
|
+
ctx: typer.Context,
|
|
261
|
+
participant: Annotated[str, typer.Option(help="Participant whose inbox should be listed.")],
|
|
262
|
+
) -> None:
|
|
263
|
+
"""List messages in one participant inbox."""
|
|
264
|
+
|
|
265
|
+
def show() -> None:
|
|
266
|
+
"""List valid items and diagnose malformed neighbors."""
|
|
267
|
+
errors: list[tuple[Path, Exception]] = []
|
|
268
|
+
for message in _runtime(ctx).read_inbox(
|
|
269
|
+
participant, on_error=lambda path, exc: errors.append((path, exc))
|
|
270
|
+
):
|
|
271
|
+
typer.echo(f"{message.id}\t{message.from_}\t{message.kind}\t{message.title}")
|
|
272
|
+
_report_invalid_files(errors)
|
|
273
|
+
|
|
274
|
+
_run(show)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@app.command()
|
|
278
|
+
def participants(ctx: typer.Context) -> None:
|
|
279
|
+
"""List participant signatures and their assigned roles."""
|
|
280
|
+
|
|
281
|
+
def show() -> None:
|
|
282
|
+
"""List valid identities and diagnose malformed neighbors."""
|
|
283
|
+
errors: list[tuple[Path, Exception]] = []
|
|
284
|
+
for signature in _runtime(ctx).list_signatures(
|
|
285
|
+
on_error=lambda path, exc: errors.append((path, exc))
|
|
286
|
+
):
|
|
287
|
+
typer.echo(f"{signature.id}\t{signature.role}\t{signature.kind}\t{signature.name}")
|
|
288
|
+
_report_invalid_files(errors)
|
|
289
|
+
|
|
290
|
+
_run(show)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _report_invalid_files(errors: list[tuple[Path, Exception]]) -> None:
|
|
294
|
+
"""Report all invalid files after rendering the valid entries."""
|
|
295
|
+
for path, error in errors:
|
|
296
|
+
typer.echo(f"invalid: {path}: {error}", err=True)
|
|
297
|
+
if errors:
|
|
298
|
+
raise typer.Exit(2)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
302
|
+
"""Run the ctlrm command-line application."""
|
|
303
|
+
app(args=list(argv) if argv is not None else None, prog_name="ctlrm")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
if __name__ == "__main__":
|
|
307
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Modules for inter-agent communication."""
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Owned terminal management through libtmux, with process identity checks."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from functools import wraps
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import shlex
|
|
9
|
+
import signal
|
|
10
|
+
import time
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
import libtmux
|
|
15
|
+
|
|
16
|
+
from ctlrm.runtime.location import runtime_path
|
|
17
|
+
from ctlrm.managed.storage import read_record
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StopPending(RuntimeError):
|
|
21
|
+
"""An owned process has been signaled but has not exited yet."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _translate_errors(method):
|
|
25
|
+
"""Keep library-specific exceptions inside the terminal adapter."""
|
|
26
|
+
|
|
27
|
+
@wraps(method)
|
|
28
|
+
def call(*args, **kwargs):
|
|
29
|
+
"""Translate a failed tmux operation into the service error contract."""
|
|
30
|
+
try:
|
|
31
|
+
return method(*args, **kwargs)
|
|
32
|
+
except libtmux.exc.LibTmuxException as error:
|
|
33
|
+
raise RuntimeError(f"tmux operation failed: {error}") from error
|
|
34
|
+
|
|
35
|
+
return call
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def process_identity(pid: int) -> str | None:
|
|
39
|
+
"""Return an OS start identity, treating dead/zombie processes as absent."""
|
|
40
|
+
try:
|
|
41
|
+
value = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()
|
|
42
|
+
if value[0] == "Z":
|
|
43
|
+
return None
|
|
44
|
+
boot = Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
|
45
|
+
return f"{boot}:{pid}:{value[19]}"
|
|
46
|
+
except FileNotFoundError:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def children(pid: int) -> list[int]:
|
|
51
|
+
"""Read direct provider children of the stable host process on Linux."""
|
|
52
|
+
try:
|
|
53
|
+
text = Path(f"/proc/{pid}/task/{pid}/children").read_text()
|
|
54
|
+
return [int(value) for value in text.split() if process_identity(int(value))]
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Terminal(Protocol):
|
|
60
|
+
"""Boundary faked by deterministic supervisor tests."""
|
|
61
|
+
|
|
62
|
+
def exists(self, spec: dict) -> bool:
|
|
63
|
+
"""Return whether the deterministic terminal name is occupied."""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
def ensure(self, spec: dict) -> dict:
|
|
67
|
+
"""Create or reconcile a verified owned terminal."""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
def adopt(self, spec: dict) -> dict:
|
|
71
|
+
"""Verify an existing launch without creating one if it disappeared."""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
def health(self, spec: dict, identity: dict) -> str:
|
|
75
|
+
"""Return running, exited, or missing; mismatched ownership raises."""
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
def wake(self, spec: dict, identity: dict, reference: str) -> None:
|
|
79
|
+
"""Deliver a bounded literal mailbox reference to a verified provider."""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
def stop(self, spec: dict, identity: dict) -> None:
|
|
83
|
+
"""Stop only the verified session and its provider processes."""
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TmuxTerminal:
|
|
88
|
+
"""One isolated server namespace per coordination area."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, area_id: str) -> None:
|
|
91
|
+
"""Use a dedicated socket and ignore user tmux configuration."""
|
|
92
|
+
self.socket = f"ctlrm-{area_id}"
|
|
93
|
+
self.server = libtmux.Server(socket_name=self.socket, config_file="/dev/null")
|
|
94
|
+
|
|
95
|
+
def _session(self, spec: dict):
|
|
96
|
+
"""Find one exact session name without selecting user terminals."""
|
|
97
|
+
return self.server.sessions.get(session_name=spec["terminal_name"], default=None)
|
|
98
|
+
|
|
99
|
+
@_translate_errors
|
|
100
|
+
def exists(self, spec: dict) -> bool:
|
|
101
|
+
"""Return whether this generation's terminal name is occupied."""
|
|
102
|
+
return self._session(spec) is not None
|
|
103
|
+
|
|
104
|
+
def _identity(self, spec: dict, session) -> dict:
|
|
105
|
+
"""Verify creation-time environment, host command, root, and process identity."""
|
|
106
|
+
panes = list(session.panes)
|
|
107
|
+
if len(panes) != 1:
|
|
108
|
+
raise ValueError("owned session has an unexpected pane layout")
|
|
109
|
+
pane = panes[0]
|
|
110
|
+
token = session.cmd("show-environment", "CTLRM_LAUNCH_TOKEN").stdout
|
|
111
|
+
if token != [f"CTLRM_LAUNCH_TOKEN={spec['token']}"]:
|
|
112
|
+
raise ValueError("terminal ownership token mismatch")
|
|
113
|
+
host_pid = int(pane.pane_pid)
|
|
114
|
+
expected = [
|
|
115
|
+
sys.executable,
|
|
116
|
+
"-m",
|
|
117
|
+
"ctlrm.managed.host",
|
|
118
|
+
spec["root"],
|
|
119
|
+
spec["id"],
|
|
120
|
+
str(spec["generation"]),
|
|
121
|
+
]
|
|
122
|
+
try:
|
|
123
|
+
command = Path(f"/proc/{host_pid}/cmdline").read_bytes().split(b"\0")
|
|
124
|
+
actual = [part.decode() for part in command if part]
|
|
125
|
+
cwd = Path(f"/proc/{host_pid}/cwd").resolve(strict=True)
|
|
126
|
+
except FileNotFoundError as error:
|
|
127
|
+
raise ValueError("terminal host disappeared during identity validation") from error
|
|
128
|
+
if actual != expected or cwd != Path(spec["root"]):
|
|
129
|
+
raise ValueError(
|
|
130
|
+
"terminal host command or worktree mismatch; inspect before reconciliation"
|
|
131
|
+
)
|
|
132
|
+
server_pid = int(pane.cmd("display-message", "-p", "#{pid}").stdout[0])
|
|
133
|
+
host_start, server_start = process_identity(host_pid), process_identity(server_pid)
|
|
134
|
+
if not host_start or not server_start:
|
|
135
|
+
raise ValueError("terminal process identity is unavailable")
|
|
136
|
+
return {
|
|
137
|
+
"socket": self.socket,
|
|
138
|
+
"socket_path": pane.cmd("display-message", "-p", "#{socket_path}").stdout[0],
|
|
139
|
+
"server": server_start,
|
|
140
|
+
"host": host_start,
|
|
141
|
+
"pane": pane.pane_id,
|
|
142
|
+
"host_pid": host_pid,
|
|
143
|
+
"session": session.session_id,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@_translate_errors
|
|
147
|
+
def ensure(self, spec: dict) -> dict:
|
|
148
|
+
"""Create a stable host or adopt only an exact creation-time identity match."""
|
|
149
|
+
session = self._session(spec)
|
|
150
|
+
if session is None:
|
|
151
|
+
command = [
|
|
152
|
+
sys.executable,
|
|
153
|
+
"-m",
|
|
154
|
+
"ctlrm.managed.host",
|
|
155
|
+
spec["root"],
|
|
156
|
+
spec["id"],
|
|
157
|
+
str(spec["generation"]),
|
|
158
|
+
]
|
|
159
|
+
session = self.server.new_session(
|
|
160
|
+
session_name=spec["terminal_name"],
|
|
161
|
+
start_directory=spec["root"],
|
|
162
|
+
window_command="exec " + shlex.join(command),
|
|
163
|
+
environment={"CTLRM_LAUNCH_TOKEN": spec["token"]},
|
|
164
|
+
x=120,
|
|
165
|
+
y=40,
|
|
166
|
+
)
|
|
167
|
+
deadline = time.monotonic() + 2
|
|
168
|
+
while True:
|
|
169
|
+
try:
|
|
170
|
+
identity = self._identity(spec, session)
|
|
171
|
+
break
|
|
172
|
+
except ValueError:
|
|
173
|
+
if time.monotonic() >= deadline:
|
|
174
|
+
raise
|
|
175
|
+
time.sleep(0.02)
|
|
176
|
+
session.set_option("@ctlrm-area", spec["area_id"])
|
|
177
|
+
session.set_option("@ctlrm-session", spec["id"])
|
|
178
|
+
session.set_option("@ctlrm-generation", str(spec["generation"]))
|
|
179
|
+
return identity
|
|
180
|
+
|
|
181
|
+
@_translate_errors
|
|
182
|
+
def adopt(self, spec: dict) -> dict:
|
|
183
|
+
"""Reconcile an existing launch without starting a process on a race."""
|
|
184
|
+
session = self._session(spec)
|
|
185
|
+
if session is None:
|
|
186
|
+
raise ValueError("launch disappeared before readiness reconciliation")
|
|
187
|
+
return self._identity(spec, session)
|
|
188
|
+
|
|
189
|
+
def _verified(self, spec: dict, identity: dict):
|
|
190
|
+
"""Resolve a recorded terminal and reject server restart/PID reuse."""
|
|
191
|
+
session = self._session(spec)
|
|
192
|
+
if session is None:
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
observed = self._identity(spec, session)
|
|
196
|
+
except (libtmux.exc.LibTmuxException, ValueError, OSError):
|
|
197
|
+
# A session can disappear after lookup. Only confirmed absence permits recovery.
|
|
198
|
+
if (
|
|
199
|
+
self._session(spec) is None
|
|
200
|
+
and process_identity(identity["host_pid"]) != identity["host"]
|
|
201
|
+
):
|
|
202
|
+
return None
|
|
203
|
+
raise
|
|
204
|
+
if observed != identity:
|
|
205
|
+
raise ValueError("terminal identity changed; refusing to adopt or stop it")
|
|
206
|
+
return session
|
|
207
|
+
|
|
208
|
+
@_translate_errors
|
|
209
|
+
def health(self, spec: dict, identity: dict) -> str:
|
|
210
|
+
"""A live host with no provider child is an exited agent, not a healthy pane."""
|
|
211
|
+
session = self._verified(spec, identity)
|
|
212
|
+
if session is None:
|
|
213
|
+
return "missing"
|
|
214
|
+
if children(identity["host_pid"]):
|
|
215
|
+
return "running"
|
|
216
|
+
exit_path = (
|
|
217
|
+
runtime_path(Path(spec["root"]))
|
|
218
|
+
/ "sessions"
|
|
219
|
+
/ spec["id"]
|
|
220
|
+
/ f"exit-{spec['generation']}.json"
|
|
221
|
+
)
|
|
222
|
+
if exit_path.exists():
|
|
223
|
+
if read_record(exit_path).get("token") != spec["token"]:
|
|
224
|
+
raise ValueError("provider exit observation has a mismatched launch token")
|
|
225
|
+
return "exited"
|
|
226
|
+
return "starting"
|
|
227
|
+
|
|
228
|
+
@_translate_errors
|
|
229
|
+
def wake(self, spec: dict, identity: dict, reference: str) -> None:
|
|
230
|
+
"""Type only a bounded ASCII reference, never a user's multiline prompt."""
|
|
231
|
+
if (
|
|
232
|
+
not reference.isascii()
|
|
233
|
+
or len(reference.encode()) > 256
|
|
234
|
+
or "\n" in reference
|
|
235
|
+
or "\r" in reference
|
|
236
|
+
):
|
|
237
|
+
raise ValueError("terminal references must be a single ASCII line of at most 256 bytes")
|
|
238
|
+
session = self._verified(spec, identity)
|
|
239
|
+
if session is None or self.health(spec, identity) != "running":
|
|
240
|
+
raise ValueError("provider is not running")
|
|
241
|
+
session.panes[0].send_keys(reference, literal=True)
|
|
242
|
+
|
|
243
|
+
@_translate_errors
|
|
244
|
+
def stop(self, spec: dict, identity: dict) -> None:
|
|
245
|
+
"""Kill only the verified dedicated session, propagating terminal hangup."""
|
|
246
|
+
session = self._verified(spec, identity)
|
|
247
|
+
if session is not None:
|
|
248
|
+
host_pid = identity["host_pid"]
|
|
249
|
+
group = os.getpgid(host_pid)
|
|
250
|
+
if group != host_pid:
|
|
251
|
+
raise ValueError("terminal host does not own its process group")
|
|
252
|
+
os.killpg(group, signal.SIGKILL)
|
|
253
|
+
# Killing the dedicated process group also closes the pane/session.
|
|
254
|
+
deadline = time.monotonic() + 2
|
|
255
|
+
while process_identity(host_pid) == identity["host"] and time.monotonic() < deadline:
|
|
256
|
+
time.sleep(0.02)
|
|
257
|
+
if process_identity(identity["host_pid"]) == identity["host"]:
|
|
258
|
+
raise StopPending("terminal stop is pending; host process still exists")
|
|
259
|
+
|
|
260
|
+
@_translate_errors
|
|
261
|
+
def capture(self, spec: dict, identity: dict) -> str:
|
|
262
|
+
"""Capture bounded recent output from a verified owned terminal."""
|
|
263
|
+
session = self._verified(spec, identity)
|
|
264
|
+
if session is None:
|
|
265
|
+
return "[terminal is missing]"
|
|
266
|
+
return "\n".join(session.panes[0].capture_pane(start=-200) or [])
|
|
267
|
+
|
|
268
|
+
@_translate_errors
|
|
269
|
+
def attach_command(self, spec: dict, identity: dict, *, readonly: bool = False) -> list[str]:
|
|
270
|
+
"""Build an interactive client command only after verifying the owned generation."""
|
|
271
|
+
session = self._verified(spec, identity)
|
|
272
|
+
if session is None:
|
|
273
|
+
raise ValueError("terminal is missing")
|
|
274
|
+
# libtmux captures stdio; native attachment needs a caller-owned terminal or PTY.
|
|
275
|
+
binary = self.server.tmux_bin or shutil.which("tmux")
|
|
276
|
+
if not binary:
|
|
277
|
+
raise RuntimeError("tmux executable is missing")
|
|
278
|
+
return [
|
|
279
|
+
binary,
|
|
280
|
+
"-S",
|
|
281
|
+
identity["socket_path"],
|
|
282
|
+
"attach-session",
|
|
283
|
+
*(["-f", "read-only"] if readonly else []),
|
|
284
|
+
"-t",
|
|
285
|
+
session.session_id,
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
def attach(self, spec: dict, identity: dict) -> None:
|
|
289
|
+
"""Attach the interactive client through a verified libtmux session."""
|
|
290
|
+
result = subprocess.run(self.attach_command(spec, identity))
|
|
291
|
+
if result.returncode:
|
|
292
|
+
raise RuntimeError(f"interactive tmux client exited with status {result.returncode}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Managed worktree sessions and durable workflow services."""
|