shell-session-manager 2.1.0__tar.gz → 2.1.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.
Files changed (32) hide show
  1. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/PKG-INFO +1 -1
  2. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/pyproject.toml +1 -1
  3. shell_session_manager-2.1.1/src/shell_session_manager/shellctl/server/artifacts.py +45 -0
  4. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/service.py +70 -10
  5. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/tmux.py +60 -10
  6. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/tests/test_shellctl_service.py +297 -0
  7. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/LICENSE +0 -0
  8. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/README.md +0 -0
  9. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/__init__.py +0 -0
  10. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/py.typed +0 -0
  11. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/session.py +0 -0
  12. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/__init__.py +0 -0
  13. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/client/__init__.py +0 -0
  14. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/client/sdk.py +0 -0
  15. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/__init__.py +0 -0
  16. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/__main__.py +0 -0
  17. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/api.py +0 -0
  18. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/cli.py +0 -0
  19. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/config.py +0 -0
  20. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/db.py +0 -0
  21. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/errors.py +0 -0
  22. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/__init__.py +0 -0
  23. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/constants.py +0 -0
  24. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/output.py +0 -0
  25. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/runtime.py +0 -0
  26. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/sanitize.py +0 -0
  27. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/schemas.py +0 -0
  28. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/tests/golden_shellctl_sanitize.json +0 -0
  29. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/tests/test_shell_session_autoclose.py +0 -0
  30. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/tests/test_shell_session_manager.py +0 -0
  31. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/tests/test_shellctl_client.py +0 -0
  32. {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/tests/test_shellctl_shared.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shell-session-manager
3
- Version: 2.1.0
3
+ Version: 2.1.1
4
4
  Summary: Async subprocess session manager with incremental stdin/stdout/stderr support
5
5
  Author-Email: =?utf-8?b?WWFubGkg55uQ57KS?= <yanli@mail.one>
6
6
  License-Expression: Apache-2.0
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "shell-session-manager"
9
- version = "2.1.0"
9
+ version = "2.1.1"
10
10
  description = "Async subprocess session manager with incremental stdin/stdout/stderr support"
11
11
  authors = [
12
12
  { name = "Yanli 盐粒", email = "yanli@mail.one" },
@@ -0,0 +1,45 @@
1
+ """Per-job artifact names used by shellctl server/runtime code.
2
+
3
+ Normal job completion is coordinated through small marker files inside each
4
+ `jobs/<job_id>/` directory so the tmux output-pipe finalizer can publish the
5
+ SQLite `exited(exit_code, ended_at)` state only after PTY output is fully
6
+ drained into `output.log`. A separate failure marker is used so an unsuccessful
7
+ sanitizer run does not masquerade as a drained normal exit.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ RUNNER_EXIT_CODE_FILENAME = ".runner-exit-code"
15
+ RUNNER_ENDED_AT_FILENAME = ".runner-ended-at"
16
+ PIPE_DRAINED_FILENAME = ".pipe-drained"
17
+ PIPE_FAILED_FILENAME = ".pipe-failed"
18
+
19
+
20
+ def runner_exit_code_path(job_dir: Path) -> Path:
21
+ return job_dir / RUNNER_EXIT_CODE_FILENAME
22
+
23
+
24
+ def runner_ended_at_path(job_dir: Path) -> Path:
25
+ return job_dir / RUNNER_ENDED_AT_FILENAME
26
+
27
+
28
+ def pipe_drained_path(job_dir: Path) -> Path:
29
+ return job_dir / PIPE_DRAINED_FILENAME
30
+
31
+
32
+ def pipe_failed_path(job_dir: Path) -> Path:
33
+ return job_dir / PIPE_FAILED_FILENAME
34
+
35
+
36
+ __all__ = [
37
+ "PIPE_DRAINED_FILENAME",
38
+ "PIPE_FAILED_FILENAME",
39
+ "RUNNER_ENDED_AT_FILENAME",
40
+ "RUNNER_EXIT_CODE_FILENAME",
41
+ "pipe_drained_path",
42
+ "pipe_failed_path",
43
+ "runner_ended_at_path",
44
+ "runner_exit_code_path",
45
+ ]
@@ -23,6 +23,14 @@ from sqlalchemy.exc import IntegrityError
23
23
  from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
24
24
  from sqlmodel import SQLModel
25
25
 
26
+ from shell_session_manager.shellctl.server.artifacts import (
27
+ RUNNER_ENDED_AT_FILENAME,
28
+ RUNNER_EXIT_CODE_FILENAME,
29
+ pipe_drained_path,
30
+ pipe_failed_path,
31
+ runner_ended_at_path,
32
+ runner_exit_code_path,
33
+ )
26
34
  from shell_session_manager.shellctl.server.config import ShellctlConfig
27
35
  from shell_session_manager.shellctl.server.db import JobRow, configure_sqlite_engine
28
36
  from shell_session_manager.shellctl.server.errors import ShellctlServerError
@@ -540,9 +548,12 @@ class ShellctlService:
540
548
  async def record_runner_exit(
541
549
  self, job_id: str, exit_code: int, ended_at: str
542
550
  ) -> None:
543
- """Persist the runner's exit fact into SQLite.
551
+ """Persist a drained normal-exit fact into SQLite.
544
552
 
545
- This is the source-of-truth replacement for the old `exit.json`. The
553
+ The usual caller is the tmux pipe finalizer after `sanitize-pty` has
554
+ reached EOF and flushed `output.log`. `_materialize_status_view()` may
555
+ also call this as a recovery path when `.pipe-drained` plus the normal
556
+ exit metadata files exist but SQLite is still non-terminal. The
546
557
  statement always records `exit_code` and `ended_at`, but it only changes
547
558
  `status` to `exited` when the current row is still non-terminal.
548
559
  """
@@ -660,6 +671,13 @@ class ShellctlService:
660
671
  - terminal SQLite status wins and is preserved
661
672
  - if SQLite already has an `exit_code` on a non-terminal row, materialize
662
673
  `exited`
674
+ - if normal-exit metadata and `.pipe-drained` already exist, recover the
675
+ drained normal exit immediately even when the finalizer has not yet
676
+ committed SQLite state
677
+ - if normal-exit metadata exists and neither `.pipe-drained` nor
678
+ `.pipe-failed` exists, keep the non-terminal row instead of guessing
679
+ `lost` while the pipe finalizer is still responsible for the eventual
680
+ `runner-exit` commit
663
681
  - if a live tmux session exists but the output pipe is known-dead,
664
682
  conditionally materialize `failed(reason=pipe_failed)`
665
683
  - if no live session exists and the row is not protected by the local
@@ -693,6 +711,10 @@ class ShellctlService:
693
711
  target=JobStatusName.EXITED,
694
712
  ended_at=row.ended_at or format_timestamp(),
695
713
  )
714
+ elif (drained_exit := self._drained_normal_exit_metadata(job_id)) is not None:
715
+ exit_code, ended_at = drained_exit
716
+ await self.record_runner_exit(job_id, exit_code, ended_at)
717
+ row = await self._get_job_row(job_id)
696
718
  elif session_exists:
697
719
  if pipe_active is False:
698
720
  if (
@@ -728,7 +750,9 @@ class ShellctlService:
728
750
  require_exit_code_null=True,
729
751
  )
730
752
  else:
731
- if not (
753
+ if self._normal_exit_commit_pending(job_id):
754
+ pass
755
+ elif not (
732
756
  status in {JobStatusName.CREATED, JobStatusName.STARTING}
733
757
  and job_id in self._starting_jobs
734
758
  ):
@@ -911,6 +935,36 @@ class ShellctlService:
911
935
  def _artifact_dir(self, job_id: str) -> Path:
912
936
  return self.config.jobs_dir / job_id
913
937
 
938
+ def _normal_exit_commit_pending(self, job_id: str) -> bool:
939
+ """Check whether a normal exit is still waiting for pipe drain + commit."""
940
+
941
+ job_dir = self._artifact_dir(job_id)
942
+ return (
943
+ runner_exit_code_path(job_dir).exists()
944
+ and runner_ended_at_path(job_dir).exists()
945
+ and not pipe_drained_path(job_dir).exists()
946
+ and not pipe_failed_path(job_dir).exists()
947
+ )
948
+
949
+ def _drained_normal_exit_metadata(self, job_id: str) -> tuple[int, str] | None:
950
+ """Read drained normal-exit metadata after successful sanitize drain."""
951
+
952
+ job_dir = self._artifact_dir(job_id)
953
+ drained_path = pipe_drained_path(job_dir)
954
+ exit_code_path = runner_exit_code_path(job_dir)
955
+ ended_at_path = runner_ended_at_path(job_dir)
956
+ if (
957
+ not drained_path.exists()
958
+ or not exit_code_path.exists()
959
+ or not ended_at_path.exists()
960
+ ):
961
+ return None
962
+ raw_exit_code = exit_code_path.read_text(encoding="utf-8").strip()
963
+ raw_ended_at = ended_at_path.read_text(encoding="utf-8").strip()
964
+ if not raw_exit_code or not raw_ended_at:
965
+ return None
966
+ return int(raw_exit_code), raw_ended_at
967
+
914
968
  def _allocate_job_dir(self) -> tuple[str, Path]:
915
969
  """Atomically allocate a unique artifact directory for a new job.
916
970
 
@@ -968,12 +1022,7 @@ class ShellctlService:
968
1022
  self.config.runner_path.chmod(mode | stat.S_IXUSR)
969
1023
 
970
1024
  def _runner_script_source(self) -> str:
971
- tmux_socket = shlex.quote(str(self.config.tmux_socket))
972
- state_dir = shlex.quote(str(self.config.state_dir))
973
1025
  auth_env = shlex.quote(DEFAULT_AUTH_TOKEN_ENV)
974
- shellctl_command = " ".join(
975
- shlex.quote(part) for part in self.config.shellctl_command
976
- )
977
1026
  return f"""#!/usr/bin/env bash
978
1027
  set -uo pipefail
979
1028
 
@@ -982,6 +1031,16 @@ JOB_ID="$2"
982
1031
  CWD="$3"
983
1032
  SCRIPT_PATH="$JOB_DIR/script"
984
1033
  START_GATE="$JOB_DIR/start-gate"
1034
+ RUNNER_EXIT_CODE_PATH="$JOB_DIR/{RUNNER_EXIT_CODE_FILENAME}"
1035
+ RUNNER_ENDED_AT_PATH="$JOB_DIR/{RUNNER_ENDED_AT_FILENAME}"
1036
+
1037
+ write_atomic() {{
1038
+ local dest="$1"
1039
+ local value="$2"
1040
+ local tmp="${{dest}}.tmp.$$"
1041
+ printf '%s\n' "$value" > "$tmp"
1042
+ mv "$tmp" "$dest"
1043
+ }}
985
1044
 
986
1045
  while [ ! -e "$START_GATE" ]; do
987
1046
  sleep 0.05
@@ -1013,8 +1072,9 @@ print(datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z'
1013
1072
  PY
1014
1073
  )"
1015
1074
 
1016
- {shellctl_command} runner-exit --state-dir {state_dir} --job-id "$JOB_ID" --exit-code "$EXIT_CODE" --ended-at "$ENDED_AT"
1017
- env -u TMUX tmux -S {tmux_socket} kill-session -t "shellctl-job-$JOB_ID" >/dev/null 2>&1 || true
1075
+ write_atomic "$RUNNER_EXIT_CODE_PATH" "$EXIT_CODE"
1076
+ write_atomic "$RUNNER_ENDED_AT_PATH" "$ENDED_AT"
1077
+
1018
1078
  exit "$EXIT_CODE"
1019
1079
  """
1020
1080
 
@@ -16,6 +16,12 @@ from typing import Protocol, cast
16
16
 
17
17
  import anyio
18
18
 
19
+ from shell_session_manager.shellctl.server.artifacts import (
20
+ pipe_drained_path,
21
+ pipe_failed_path,
22
+ runner_ended_at_path,
23
+ runner_exit_code_path,
24
+ )
19
25
  from shell_session_manager.shellctl.server.config import ShellctlConfig
20
26
  from shell_session_manager.shellctl.server.errors import ShellctlServerError
21
27
  from shell_session_manager.shellctl.shared import (
@@ -148,16 +154,10 @@ class TmuxController:
148
154
  async def enable_output_pipe(
149
155
  self, *, job_id: str, job_dir: Path, ready_file: Path
150
156
  ) -> None:
151
- sanitize_command = self._shell_join(
152
- (
153
- *self._config.shellctl_command,
154
- "sanitize-pty",
155
- "--ready-file",
156
- str(ready_file),
157
- )
158
- )
159
- output_command = (
160
- f"{sanitize_command} >> {shlex.quote(str(job_dir / 'output.log'))}"
157
+ output_command = self._pipe_command_source(
158
+ job_id=job_id,
159
+ job_dir=job_dir,
160
+ ready_file=ready_file,
161
161
  )
162
162
  result = await self._run_tmux(
163
163
  "pipe-pane",
@@ -175,6 +175,55 @@ class TmuxController:
175
175
  or f"Failed to attach output pipe for {job_id}",
176
176
  )
177
177
 
178
+ def _pipe_command_source(
179
+ self, *, job_id: str, job_dir: Path, ready_file: Path
180
+ ) -> str:
181
+ """Build the tmux `pipe-pane` command that drains and finalizes output.
182
+
183
+ For normal exits, the runner now records completion metadata into job
184
+ artifacts and the pipe finalizer commits `runner-exit` only after
185
+ `sanitize-pty` reaches EOF and flushes `output.log` successfully.
186
+ """
187
+
188
+ sanitize_command = self._shell_join(
189
+ (
190
+ *self._config.shellctl_command,
191
+ "sanitize-pty",
192
+ "--ready-file",
193
+ str(ready_file),
194
+ )
195
+ )
196
+ runner_exit_command = self._shell_join(
197
+ (
198
+ *self._config.shellctl_command,
199
+ "runner-exit",
200
+ "--state-dir",
201
+ str(self._config.state_dir),
202
+ "--job-id",
203
+ job_id,
204
+ )
205
+ )
206
+ output_path = shlex.quote(str(job_dir / "output.log"))
207
+ drained_path = shlex.quote(str(pipe_drained_path(job_dir)))
208
+ failed_path = shlex.quote(str(pipe_failed_path(job_dir)))
209
+ exit_code_path = shlex.quote(str(runner_exit_code_path(job_dir)))
210
+ ended_at_path = shlex.quote(str(runner_ended_at_path(job_dir)))
211
+ return " ; ".join(
212
+ [
213
+ f"{sanitize_command} >> {output_path}",
214
+ "sanitize_status=$?",
215
+ (
216
+ 'if [ "$sanitize_status" -eq 0 ]; then '
217
+ f": > {drained_path}; "
218
+ f"if [ -s {exit_code_path} ] && [ -s {ended_at_path} ]; then "
219
+ f'{runner_exit_command} --exit-code "$(cat {exit_code_path})" '
220
+ f'--ended-at "$(cat {ended_at_path})"; fi; '
221
+ f"else : > {failed_path}; fi"
222
+ ),
223
+ 'exit "$sanitize_status"',
224
+ ]
225
+ )
226
+
178
227
  async def send_input(self, *, job_id: str, text: str) -> None:
179
228
  buffer_name = f"shellctl-in-{job_id}"
180
229
  runtime_dir = cast(Path, self._config.runtime_dir)
@@ -286,6 +335,7 @@ def _tmux_target_missing(stderr: str) -> bool:
286
335
  or "can't find session" in normalized
287
336
  or "no server running" in normalized
288
337
  or "failed to connect" in normalized
338
+ or "server exited unexpectedly" in normalized
289
339
  )
290
340
 
291
341
 
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import importlib
4
4
  import os
5
+ import re
6
+ import shutil
5
7
  import sqlite3
6
8
  import subprocess
7
9
  import sys
@@ -23,6 +25,13 @@ from shell_session_manager.shellctl.server import (
23
25
  cli,
24
26
  create_app,
25
27
  )
28
+ from shell_session_manager.shellctl.server.artifacts import (
29
+ PIPE_DRAINED_FILENAME,
30
+ PIPE_FAILED_FILENAME,
31
+ RUNNER_ENDED_AT_FILENAME,
32
+ RUNNER_EXIT_CODE_FILENAME,
33
+ )
34
+ from shell_session_manager.shellctl.server.tmux import TmuxController
26
35
  from shell_session_manager.shellctl.shared import (
27
36
  DEFAULT_AUTH_TOKEN_ENV,
28
37
  InputJobRequest,
@@ -106,6 +115,62 @@ async def _create_service(
106
115
  return service, fake_tmux
107
116
 
108
117
 
118
+ async def _create_real_service(
119
+ tmp_path: Path,
120
+ *,
121
+ shellctl_command: tuple[str, ...] | None = None,
122
+ ) -> ShellctlService:
123
+ config = ShellctlConfig(
124
+ state_dir=tmp_path / "state",
125
+ runtime_dir=tmp_path / "run",
126
+ shellctl_command=shellctl_command
127
+ or ShellctlConfig(
128
+ state_dir=tmp_path / "default-state",
129
+ runtime_dir=tmp_path / "default-run",
130
+ ).shellctl_command,
131
+ )
132
+ service = ShellctlService(config)
133
+ await service.initialize()
134
+ return service
135
+
136
+
137
+ def _write_delayed_sanitize_wrapper(
138
+ tmp_path: Path,
139
+ *,
140
+ delay_seconds: float,
141
+ sanitize_exit_code: int | None = None,
142
+ ) -> tuple[str, ...]:
143
+ wrapper_path = tmp_path / "delayed-sanitize-wrapper.py"
144
+ wrapper_path.write_text(
145
+ "\n".join(
146
+ [
147
+ "from __future__ import annotations",
148
+ "",
149
+ "import subprocess",
150
+ "import sys",
151
+ "import time",
152
+ "from pathlib import Path",
153
+ "",
154
+ f"REAL = [{sys.executable!r}, '-m', 'shell_session_manager.shellctl.server']",
155
+ "args = sys.argv[1:]",
156
+ "if args[:1] == ['sanitize-pty'] and '--ready-file' in args:",
157
+ " ready_file = Path(args[args.index('--ready-file') + 1])",
158
+ " ready_file.touch()",
159
+ f" time.sleep({delay_seconds!r})",
160
+ (
161
+ f" raise SystemExit({sanitize_exit_code!r})"
162
+ if sanitize_exit_code is not None
163
+ else ""
164
+ ),
165
+ "raise SystemExit(subprocess.run(REAL + args, check=False).returncode)",
166
+ "",
167
+ ]
168
+ ),
169
+ encoding="utf-8",
170
+ )
171
+ return (sys.executable, str(wrapper_path))
172
+
173
+
109
174
  def _capture_serve_config(
110
175
  monkeypatch: pytest.MonkeyPatch,
111
176
  ) -> tuple[dict[str, object], CliRunner]:
@@ -324,6 +389,174 @@ async def test_run_job_happy_path_persists_running_row_and_artifacts(
324
389
  await service.shutdown()
325
390
 
326
391
 
392
+ @pytest.mark.anyio
393
+ @pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
394
+ async def test_run_job_returns_flushed_output_on_first_terminal_result(
395
+ tmp_path: Path,
396
+ ) -> None:
397
+ shellctl_command = _write_delayed_sanitize_wrapper(tmp_path, delay_seconds=0.2)
398
+ service = await _create_real_service(
399
+ tmp_path,
400
+ shellctl_command=shellctl_command,
401
+ )
402
+
403
+ try:
404
+ result = await service.run_job(
405
+ RunJobRequest(
406
+ script="printf 'delayed-flush\\n'\n",
407
+ cwd=str(tmp_path),
408
+ terminal=TerminalSize(),
409
+ timeout=3,
410
+ output_limit=8192,
411
+ idle_flush_seconds=1,
412
+ )
413
+ )
414
+
415
+ reread = await service.wait_job(
416
+ result.job_id,
417
+ WaitJobRequest(
418
+ offset=0,
419
+ timeout=0.001,
420
+ output_limit=8192,
421
+ idle_flush_seconds=0.01,
422
+ ),
423
+ )
424
+
425
+ assert result.done is True
426
+ assert result.status is JobStatusName.EXITED
427
+ assert result.exit_code == 0
428
+ assert result.output == "delayed-flush\n"
429
+ assert reread.output == "delayed-flush\n"
430
+ assert reread.offset == result.offset
431
+ finally:
432
+ await service.shutdown()
433
+
434
+
435
+ @pytest.mark.anyio
436
+ @pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
437
+ async def test_sanitize_failure_does_not_commit_normal_exit(
438
+ tmp_path: Path,
439
+ ) -> None:
440
+ shellctl_command = _write_delayed_sanitize_wrapper(
441
+ tmp_path,
442
+ delay_seconds=0.2,
443
+ sanitize_exit_code=7,
444
+ )
445
+ service = await _create_real_service(
446
+ tmp_path,
447
+ shellctl_command=shellctl_command,
448
+ )
449
+
450
+ try:
451
+ result = await service.run_job(
452
+ RunJobRequest(
453
+ script="printf 'missing-output\\n'\n",
454
+ cwd=str(tmp_path),
455
+ terminal=TerminalSize(),
456
+ timeout=3,
457
+ output_limit=8192,
458
+ idle_flush_seconds=1,
459
+ )
460
+ )
461
+
462
+ reread = await service.wait_job(
463
+ result.job_id,
464
+ WaitJobRequest(
465
+ offset=0,
466
+ timeout=0.001,
467
+ output_limit=8192,
468
+ idle_flush_seconds=0.01,
469
+ ),
470
+ )
471
+ job_dir = service.config.jobs_dir / result.job_id
472
+
473
+ assert result.done is True
474
+ assert result.status is not JobStatusName.EXITED
475
+ assert result.exit_code is None
476
+ assert result.output == ""
477
+ assert reread.output == ""
478
+ assert not (job_dir / PIPE_DRAINED_FILENAME).exists()
479
+ assert (job_dir / PIPE_FAILED_FILENAME).exists()
480
+ finally:
481
+ await service.shutdown()
482
+
483
+
484
+ @pytest.mark.anyio
485
+ @pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
486
+ @pytest.mark.skipif(shutil.which("uv") is None, reason="uv is required")
487
+ async def test_uv_quiet_shebang_returns_output_in_first_terminal_result(
488
+ tmp_path: Path,
489
+ ) -> None:
490
+ service = await _create_real_service(tmp_path)
491
+
492
+ try:
493
+ result = await service.run_job(
494
+ RunJobRequest(
495
+ script=(
496
+ "#!/usr/bin/env -S uv run --script --quiet\n"
497
+ "# /// script\n"
498
+ '# requires-python = ">=3.12"\n'
499
+ "# dependencies = []\n"
500
+ "# ///\n"
501
+ 'print("hello")\n'
502
+ ),
503
+ cwd=str(tmp_path),
504
+ terminal=TerminalSize(),
505
+ timeout=10,
506
+ output_limit=8192,
507
+ idle_flush_seconds=1,
508
+ )
509
+ )
510
+
511
+ assert result.done is True
512
+ assert result.status is JobStatusName.EXITED
513
+ assert result.exit_code == 0
514
+ assert result.output == "hello\n"
515
+ finally:
516
+ await service.shutdown()
517
+
518
+
519
+ @pytest.mark.anyio
520
+ @pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
521
+ @pytest.mark.skipif(shutil.which("bash") is None, reason="bash is required")
522
+ @pytest.mark.parametrize(
523
+ ("script", "expected_output"),
524
+ [
525
+ ("printf 'no-shebang\\n'\n", "no-shebang\n"),
526
+ ("#!/bin/sh\nprintf 'sh-shebang\\n'\n", "sh-shebang\n"),
527
+ (
528
+ "#!/usr/bin/env bash\nprintf 'bash-shebang\\n'\n",
529
+ "bash-shebang\n",
530
+ ),
531
+ ],
532
+ )
533
+ async def test_existing_script_modes_still_return_output(
534
+ tmp_path: Path,
535
+ script: str,
536
+ expected_output: str,
537
+ ) -> None:
538
+ service = await _create_real_service(tmp_path)
539
+
540
+ try:
541
+ result = await service.run_job(
542
+ RunJobRequest(
543
+ script=script,
544
+ cwd=str(tmp_path),
545
+ terminal=TerminalSize(),
546
+ timeout=5,
547
+ output_limit=8192,
548
+ idle_flush_seconds=1,
549
+ )
550
+ )
551
+
552
+ assert result.done is True
553
+ assert result.status is JobStatusName.EXITED
554
+ assert result.exit_code == 0
555
+ assert result.output == expected_output
556
+ finally:
557
+ await service.shutdown()
558
+
559
+
327
560
  @pytest.mark.anyio
328
561
  async def test_run_job_retries_after_sqlite_insert_conflict_and_cleans_artifacts(
329
562
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -557,6 +790,32 @@ async def test_session_disappearing_between_probes_materializes_lost_not_pipe_fa
557
790
  await service.shutdown()
558
791
 
559
792
 
793
+ @pytest.mark.anyio
794
+ async def test_drained_uncommitted_normal_exit_self_commits_to_exited(
795
+ tmp_path: Path,
796
+ ) -> None:
797
+ service, _fake_tmux = await _create_service(tmp_path)
798
+ job_id = "05211530-k7p"
799
+ await _seed_job(service, job_id=job_id, status=JobStatusName.RUNNING)
800
+ job_dir = service.config.jobs_dir / job_id
801
+ (job_dir / RUNNER_EXIT_CODE_FILENAME).write_text("0\n", encoding="utf-8")
802
+ (job_dir / RUNNER_ENDED_AT_FILENAME).write_text(
803
+ "2026-05-21T15:30:20Z\n", encoding="utf-8"
804
+ )
805
+ (job_dir / PIPE_DRAINED_FILENAME).touch()
806
+
807
+ view = await service.get_job_status(job_id)
808
+ row = await service._get_job_row(job_id)
809
+
810
+ assert view.status is JobStatusName.EXITED
811
+ assert view.done is True
812
+ assert view.exit_code == 0
813
+ assert row.status == JobStatusName.EXITED.value
814
+ assert row.exit_code == 0
815
+ assert row.ended_at == "2026-05-21T15:30:20Z"
816
+ await service.shutdown()
817
+
818
+
560
819
  @pytest.mark.anyio
561
820
  async def test_list_jobs_ignores_half_created_directory_and_uses_db_rows(
562
821
  tmp_path: Path,
@@ -1074,6 +1333,44 @@ def test_serve_cli_reads_auth_token_from_environment(
1074
1333
  assert config.auth_token == "env-token"
1075
1334
 
1076
1335
 
1336
+ def test_runner_script_records_completion_metadata_without_direct_runner_exit(
1337
+ tmp_path: Path,
1338
+ ) -> None:
1339
+ service = ShellctlService(
1340
+ ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run"),
1341
+ tmux=FakeTmuxController(),
1342
+ )
1343
+ source = service._runner_script_source()
1344
+
1345
+ assert RUNNER_EXIT_CODE_FILENAME in source
1346
+ assert RUNNER_ENDED_AT_FILENAME in source
1347
+ assert "write_atomic" in source
1348
+ assert 'mv "$tmp" "$dest"' in source
1349
+ assert re.search(r"\brunner-exit\s+--state-dir\b", source) is None
1350
+ anyio.run(service.shutdown)
1351
+
1352
+
1353
+ def test_pipe_command_finalizer_commits_runner_exit_after_drain(tmp_path: Path) -> None:
1354
+ controller = TmuxController(
1355
+ ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run")
1356
+ )
1357
+ source = controller._pipe_command_source(
1358
+ job_id="05211530-k7p",
1359
+ job_dir=tmp_path / "state" / "jobs" / "05211530-k7p",
1360
+ ready_file=tmp_path / "state" / "jobs" / "05211530-k7p" / ".pipe-ready",
1361
+ )
1362
+
1363
+ assert "sanitize-pty" in source
1364
+ assert PIPE_DRAINED_FILENAME in source
1365
+ assert PIPE_FAILED_FILENAME in source
1366
+ assert RUNNER_EXIT_CODE_FILENAME in source
1367
+ assert RUNNER_ENDED_AT_FILENAME in source
1368
+ assert re.search(r"\brunner-exit\b", source) is not None
1369
+ assert 'if [ "$sanitize_status" -eq 0 ]' in source
1370
+ assert source.index("sanitize-pty") < source.index(PIPE_DRAINED_FILENAME)
1371
+ assert source.index(PIPE_DRAINED_FILENAME) < source.index("runner-exit")
1372
+
1373
+
1077
1374
  def test_runner_exit_cli_accepts_runner_option_contract(tmp_path: Path) -> None:
1078
1375
  async def setup_running_job() -> ShellctlConfig:
1079
1376
  service, _fake_tmux = await _create_service(tmp_path)