pocketshell 0.4.39__tar.gz → 0.4.41__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 (64) hide show
  1. {pocketshell-0.4.39 → pocketshell-0.4.41}/PKG-INFO +1 -1
  2. {pocketshell-0.4.39 → pocketshell-0.4.41}/pyproject.toml +1 -1
  3. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_watch_ci.py +210 -6
  4. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_watch_ci_cancelled.py +10 -1
  5. {pocketshell-0.4.39 → pocketshell-0.4.41}/.gitignore +0 -0
  6. {pocketshell-0.4.39 → pocketshell-0.4.41}/README.md +0 -0
  7. {pocketshell-0.4.39 → pocketshell-0.4.41}/scheduler/README.md +0 -0
  8. {pocketshell-0.4.39 → pocketshell-0.4.41}/scheduler/pocketshell-usage-capture.service +0 -0
  9. {pocketshell-0.4.39 → pocketshell-0.4.41}/scheduler/pocketshell-usage-capture.timer +0 -0
  10. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/__init__.py +0 -0
  11. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/__main__.py +0 -0
  12. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/agent_card_push.py +0 -0
  13. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/agent_log.py +0 -0
  14. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/agents.py +0 -0
  15. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/agents_kind.py +0 -0
  16. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/cards.py +0 -0
  17. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/cgroup_agents.py +0 -0
  18. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/cli.py +0 -0
  19. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/daemon.py +0 -0
  20. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/env.py +0 -0
  21. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/github.py +0 -0
  22. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/hooks.py +0 -0
  23. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/jobs.py +0 -0
  24. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/logs.py +0 -0
  25. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/profiles.py +0 -0
  26. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/prune_attachments.py +0 -0
  27. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/push.py +0 -0
  28. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/qr_share.py +0 -0
  29. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/repos.py +0 -0
  30. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/resume.py +0 -0
  31. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/sessions.py +0 -0
  32. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/tree.py +0 -0
  33. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/usage.py +0 -0
  34. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/usage_capture.py +0 -0
  35. {pocketshell-0.4.39 → pocketshell-0.4.41}/src/pocketshell/usage_reset.py +0 -0
  36. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/__init__.py +0 -0
  37. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/data/quse-0.0.11-usage.json +0 -0
  38. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/data/quse-0.0.9-usage.json +0 -0
  39. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_agent_card_push.py +0 -0
  40. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_agent_log.py +0 -0
  41. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_agents.py +0 -0
  42. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_agents_kind.py +0 -0
  43. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_cards.py +0 -0
  44. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_cards_push_notify.py +0 -0
  45. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_cgroup_agents.py +0 -0
  46. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_cli.py +0 -0
  47. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_daemon.py +0 -0
  48. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_env.py +0 -0
  49. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_github.py +0 -0
  50. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_hooks.py +0 -0
  51. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_jobs.py +0 -0
  52. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_logs.py +0 -0
  53. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_profiles.py +0 -0
  54. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_prune_attachments.py +0 -0
  55. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_push.py +0 -0
  56. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_qr_share.py +0 -0
  57. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_repos.py +0 -0
  58. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_resume.py +0 -0
  59. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_sessions.py +0 -0
  60. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_tree.py +0 -0
  61. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_usage.py +0 -0
  62. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_usage_capture.py +0 -0
  63. {pocketshell-0.4.39 → pocketshell-0.4.41}/tests/test_usage_reset.py +0 -0
  64. {pocketshell-0.4.39 → pocketshell-0.4.41}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.4.39
3
+ Version: 0.4.41
4
4
  Summary: Unified server-side Python utility for the PocketShell Android client.
5
5
  Project-URL: Homepage, https://github.com/alexeygrigorev/pocketshell
6
6
  Project-URL: Issues, https://github.com/alexeygrigorev/pocketshell/issues
@@ -8,7 +8,7 @@ name = "pocketshell"
8
8
  # scripts/check-pypi-version.sh enforces this; .github/workflows/build.yml
9
9
  # runs that check before publishing to PyPI. See
10
10
  # tools/pocketshell/README.md ("Release flow") for the bump procedure.
11
- version = "0.4.39"
11
+ version = "0.4.41"
12
12
  description = "Unified server-side Python utility for the PocketShell Android client."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -10,10 +10,12 @@ Exit-code contract under test:
10
10
 
11
11
  0 green — all required checks passed
12
12
  1 failed — a required check GENUINELY failed
13
- 2 hang — no progress past --no-progress-timeout OR wall-clock cap
13
+ 2 hang — no progress past --no-progress-timeout
14
14
  3 unresolved — run / inputs couldn't be resolved (or gh stayed broken)
15
15
  4 superseded — a newer run replaced this one (routine concurrency-cancel)
16
16
  5 no_verdict — cancelled, so no verdict was reached (issue #1650)
17
+ 6 failed_fast — required check failed while the run is still in flight
18
+ 7 wall_timeout — max wall-clock reached while the run is still in flight
17
19
 
18
20
  The load-bearing case is the HANG one: a run whose jobs NEVER change state must
19
21
  exit 2, not loop forever. The test drives a virtual clock so a stalled run is
@@ -33,6 +35,7 @@ import pytest
33
35
 
34
36
  _REPO_ROOT = Path(__file__).resolve().parents[3]
35
37
  _WATCH_CI_PATH = _REPO_ROOT / "scripts" / "watch-ci.py"
38
+ _ONCALL_PROMPT_PATH = _REPO_ROOT / ".claude" / "agents" / "oncall-engineer.md"
36
39
 
37
40
 
38
41
  def _load_module():
@@ -193,6 +196,8 @@ def test_required_failure_exits_1_and_names_job():
193
196
  outcome = watcher.watch(run_id="123")
194
197
  assert outcome.result == wci.RESULT_FAILED
195
198
  assert outcome.exit_code == 1
199
+ assert outcome.exit_reason == wci.EXIT_REASON_RUN_COMPLETED
200
+ assert outcome.run_completed is True
196
201
  assert "Unit tests" in outcome.failing_jobs
197
202
  # Signature captured from the failed-step log.
198
203
  assert outcome.signature is not None
@@ -200,6 +205,30 @@ def test_required_failure_exits_1_and_names_job():
200
205
  assert outcome.likely_infra is False
201
206
 
202
207
 
208
+ def test_required_failure_while_run_in_progress_has_distinct_fail_fast_exit():
209
+ """Issue #1873: an early required-check failure is not a completed run.
210
+
211
+ The distinct exit code + cause prevent an on-call from treating the
212
+ watcher's exit as evidence that artifacts are final.
213
+ """
214
+ gh = FakeGh()
215
+ jobs = _all_required_jobs("in_progress", None)
216
+ jobs[0] = _job("Unit tests", "completed", "failure", db_id=99)
217
+ gh.queue_run_state(status="in_progress", conclusion=None, jobs=jobs)
218
+ gh.log_text = "Unit tests\tRun pytest\t2026-07-29T10:00:00Z BUILD FAILED\n"
219
+ watcher, _ = _make_watcher(gh)
220
+
221
+ outcome = watcher.watch(run_id="123")
222
+
223
+ assert outcome.result == wci.RESULT_FAILED
224
+ assert outcome.exit_code == wci.EXIT_REQUIRED_CHECK_FAILED_FAST
225
+ assert outcome.exit_reason == wci.EXIT_REASON_REQUIRED_CHECK_FAILED_FAST
226
+ assert outcome.run_completed is False
227
+ summary = wci.render_human_summary(outcome)
228
+ assert "RUN IS STILL IN FLIGHT" in summary
229
+ assert "artifacts are not final" in summary
230
+
231
+
203
232
  def test_cancelled_run_is_not_a_failure():
204
233
  """Issue #1650: `cancelled` is NOT a failure — it is a NON-verdict.
205
234
 
@@ -246,6 +275,8 @@ def test_no_progress_stall_exits_2_does_not_loop_forever():
246
275
  outcome = watcher.watch(run_id="123")
247
276
  assert outcome.result == wci.RESULT_HANG
248
277
  assert outcome.exit_code == 2
278
+ assert outcome.exit_reason == wci.EXIT_REASON_HANG_DETECTED
279
+ assert outcome.run_completed is False
249
280
  assert "no job-state progress" in outcome.reason
250
281
  # Proven bounded: it stopped well before the wall-clock cap.
251
282
  assert clock.now() < 200.0
@@ -269,12 +300,11 @@ def test_progress_then_stall_resets_no_progress_window():
269
300
  assert outcome.exit_code == 2
270
301
 
271
302
 
272
- # ── 2 hang: wall-clock cap ───────────────────────────────────────────────────
303
+ # ── 7 wall-clock timeout ─────────────────────────────────────────────────────
273
304
 
274
305
 
275
- def test_over_wall_clock_exits_2():
276
- """A run that keeps making progress but never finishes hits the wall-clock
277
- cap and exits 2 — never waits forever even while 'progressing'."""
306
+ def test_over_wall_clock_exits_7():
307
+ """A progressing run that never finishes gets the distinct wall-time code."""
278
308
  gh = FakeGh()
279
309
  # Each poll reports a DIFFERENT job state so the no-progress timer keeps
280
310
  # resetting; only the wall-clock cap can stop it.
@@ -289,11 +319,49 @@ def test_over_wall_clock_exits_2():
289
319
  )
290
320
  outcome = watcher.watch(run_id="123")
291
321
  assert outcome.result == wci.RESULT_HANG
292
- assert outcome.exit_code == 2
322
+ assert outcome.exit_code == wci.EXIT_WALL_CLOCK_TIMEOUT
323
+ assert outcome.exit_reason == wci.EXIT_REASON_WALL_CLOCK_TIMEOUT
293
324
  assert "wall-clock" in outcome.reason
294
325
  assert clock.now() >= 50.0
295
326
 
296
327
 
328
+ def test_single_job_rerun_defensively_raises_too_short_no_progress_timeout():
329
+ """Issue #1873: a one-job rerun has no intermediate transitions.
330
+
331
+ A caller-provided 3000s timeout must not falsely classify a healthy
332
+ 52-minute job as hung; detecting the one-job shape raises the effective
333
+ guard to the watcher-safe default.
334
+ """
335
+ gh = FakeGh()
336
+ single_job = [_job("Unit tests", "in_progress", None, db_id=99)]
337
+ for _ in range(313):
338
+ gh.queue_run_state(
339
+ status="in_progress",
340
+ conclusion=None,
341
+ jobs=single_job,
342
+ )
343
+ gh.queue_run_state(
344
+ status="completed",
345
+ conclusion="success",
346
+ jobs=[_job("Unit tests", "completed", "success", db_id=99)],
347
+ )
348
+ notices: list[str] = []
349
+ watcher, clock = _make_watcher(
350
+ gh,
351
+ interval_s=10.0,
352
+ no_progress_timeout_s=3000.0,
353
+ max_wall_clock_s=10000.0,
354
+ notice=notices.append,
355
+ )
356
+
357
+ outcome = watcher.watch(run_id="123")
358
+
359
+ assert clock.now() > 3000.0
360
+ assert outcome.result == wci.RESULT_GREEN
361
+ assert any("single-job rerun" in notice for notice in notices)
362
+ assert any("6000s" in notice for notice in notices)
363
+
364
+
297
365
  # ── 3 unresolved ─────────────────────────────────────────────────────────────
298
366
 
299
367
 
@@ -567,6 +635,39 @@ def test_help_documents_flags(capsys):
567
635
  assert flag in help_text
568
636
 
569
637
 
638
+ def test_help_states_exact_wall_clock_exit_and_json_field_relationship():
639
+ """Reviewer follow-up: public wording is part of the exit contract.
640
+
641
+ Merely asserting that the flags exist let contradictory exit-2/exit-7 help
642
+ stay green. Assert the option and JSON-field contract at their sources so
643
+ argparse wrapping cannot weaken the check.
644
+ """
645
+ parser = wci.build_arg_parser()
646
+ max_wall_clock = next(
647
+ action
648
+ for action in parser._actions
649
+ if "--max-wall-clock" in action.option_strings
650
+ )
651
+
652
+ assert max_wall_clock.help == (
653
+ "Exit 7 (wall-clock timeout) if the run exceeds this many seconds total."
654
+ )
655
+ assert (
656
+ "The JSON result is the CI semantic outcome; exit_code and exit_reason "
657
+ "identify why watching stopped."
658
+ ) in parser.description
659
+
660
+
661
+ def test_module_header_distinguishes_semantic_result_from_termination_contract():
662
+ """The module-level public contract must not equate result with exit cause."""
663
+ assert (
664
+ "Final JSON contract: `result` is the semantic CI outcome; `exit_code` "
665
+ "and `exit_reason` identify why the watcher stopped, and `run_completed` "
666
+ "says whether artifacts are final."
667
+ ) in wci.__doc__
668
+ assert "Exit-code contract (also the final JSON's `result`)" not in wci.__doc__
669
+
670
+
570
671
  def test_main_prints_compact_summary_and_json(monkeypatch, capsys):
571
672
  """main() should print a human summary + a final parseable JSON line and
572
673
  return the contract exit code, with stdout staying compact."""
@@ -589,6 +690,107 @@ def test_main_prints_compact_summary_and_json(monkeypatch, capsys):
589
690
  assert "required" in payload
590
691
 
591
692
 
693
+ def test_main_writes_final_summary_and_json_to_log_file(monkeypatch, tmp_path, capsys):
694
+ """Issue #1873 owner follow-up: stdout may disappear with its wrapper.
695
+
696
+ The durable log must therefore contain the exact final human summary and
697
+ machine-readable JSON verdict, not only polling heartbeats.
698
+ """
699
+ gh = FakeGh()
700
+ gh.queue_run_state(status="completed", conclusion="success", jobs=_all_required_jobs())
701
+ monkeypatch.setattr(wci, "GhRunner", lambda *a, **k: gh)
702
+ log_path = tmp_path / "watch.log"
703
+
704
+ rc = wci.main(
705
+ ["--run-id", "123", "--quiet", "--log-file", str(log_path)]
706
+ )
707
+
708
+ assert rc == 0
709
+ stdout = capsys.readouterr().out.strip()
710
+ durable = log_path.read_text(encoding="utf-8")
711
+ assert stdout in durable
712
+ payload = json.loads(durable.strip().splitlines()[-1])
713
+ assert payload["result"] == "green"
714
+ assert payload["exit_reason"] == wci.EXIT_REASON_RUN_COMPLETED
715
+ assert payload["run_completed"] is True
716
+
717
+
718
+ class _LostLauncherStream:
719
+ """A systemd-run --pipe stream whose launcher-side reader disappeared."""
720
+
721
+ def write(self, _text):
722
+ raise BrokenPipeError("launcher pipe is gone")
723
+
724
+ def flush(self):
725
+ raise BrokenPipeError("launcher pipe is gone")
726
+
727
+
728
+ def test_main_finishes_durable_contract_after_launcher_streams_disappear(
729
+ monkeypatch, tmp_path
730
+ ):
731
+ """Issue #1873 recurrence: console loss must not abort the durable verdict.
732
+
733
+ The live watcher survived its launching shell, then died with status 120 on
734
+ a later state-change write. Reproduce both lost streams before the first
735
+ notice and require the log-file contract to finish independently.
736
+ """
737
+ gh = FakeGh()
738
+ gh.queue_run_state(
739
+ status="in_progress",
740
+ conclusion=None,
741
+ jobs=_all_required_jobs("in_progress", None),
742
+ )
743
+ gh.queue_run_state(
744
+ status="completed",
745
+ conclusion="success",
746
+ jobs=_all_required_jobs(),
747
+ )
748
+ monkeypatch.setattr(wci, "GhRunner", lambda *a, **k: gh)
749
+ monkeypatch.setattr(wci.sys, "stdout", _LostLauncherStream())
750
+ monkeypatch.setattr(wci.sys, "stderr", _LostLauncherStream())
751
+ log_path = tmp_path / "watch.log"
752
+
753
+ rc = wci.main(
754
+ [
755
+ "--run-id",
756
+ "123",
757
+ "--interval",
758
+ "0",
759
+ "--log-file",
760
+ str(log_path),
761
+ ]
762
+ )
763
+
764
+ assert rc == 0
765
+ payload = json.loads(log_path.read_text(encoding="utf-8").strip().splitlines()[-1])
766
+ assert payload["result"] == "green"
767
+ assert payload["exit_reason"] == wci.EXIT_REASON_RUN_COMPLETED
768
+ assert payload["run_completed"] is True
769
+
770
+
771
+ def test_oncall_recipe_preserves_verdict_and_requires_completed_confirmation():
772
+ """Issue #1873: the durable operating instructions must close both traps."""
773
+ prompt = _ONCALL_PROMPT_PATH.read_text(encoding="utf-8")
774
+
775
+ assert "Do not add `--collect`" in prompt
776
+ assert "--log-file" in prompt
777
+ assert "systemctl --user reset-failed" in prompt
778
+ assert "Always confirm\n`status=completed`" in prompt
779
+ assert "required_check_failed_fast" in prompt
780
+ assert "artifacts are not final" in prompt
781
+
782
+
783
+ def test_oncall_systemd_recipe_does_not_bind_watcher_output_to_launcher_pipe():
784
+ """Issue #1873 recurrence: the transient service must own its output sink."""
785
+ prompt = _ONCALL_PROMPT_PATH.read_text(encoding="utf-8")
786
+ recipe = prompt.split("```bash", 2)[2].split("```", 1)[0]
787
+
788
+ assert "systemd-run --user" in recipe
789
+ assert "--wait" in recipe
790
+ assert "--pipe" not in recipe
791
+ assert "stdout and stderr go to the unit journal" in prompt
792
+
793
+
592
794
  def test_render_human_summary_under_25_lines_on_failure():
593
795
  outcome = wci.WatchOutcome(
594
796
  result=wci.RESULT_FAILED,
@@ -601,6 +803,8 @@ def test_render_human_summary_under_25_lines_on_failure():
601
803
  run_id="123",
602
804
  polls=4,
603
805
  elapsed_s=42.0,
806
+ exit_reason=wci.EXIT_REASON_RUN_COMPLETED,
807
+ run_completed=True,
604
808
  )
605
809
  text = wci.render_human_summary(outcome)
606
810
  assert text.count("\n") < 25
@@ -526,7 +526,7 @@ def test_healthy_long_running_emulator_shard_is_not_a_hang():
526
526
  assert outcome.exit_code == 0
527
527
 
528
528
 
529
- # ── The exit-code contract stays a single source of truth ────────────────────
529
+ # ── The exit-code contract stays complete and distinct ───────────────────────
530
530
 
531
531
 
532
532
  def test_exit_code_contract_is_complete_and_distinct():
@@ -537,6 +537,15 @@ def test_exit_code_contract_is_complete_and_distinct():
537
537
  assert codes[wci.RESULT_UNRESOLVED] == 3
538
538
  assert codes[wci.RESULT_SUPERSEDED] == 4
539
539
  assert codes[wci.RESULT_NO_VERDICT] == 5
540
+ assert wci.EXIT_REQUIRED_CHECK_FAILED_FAST == 6
541
+ assert wci.EXIT_WALL_CLOCK_TIMEOUT == 7
542
+ assert len(
543
+ {
544
+ *codes.values(),
545
+ wci.EXIT_REQUIRED_CHECK_FAILED_FAST,
546
+ wci.EXIT_WALL_CLOCK_TIMEOUT,
547
+ }
548
+ ) == 8
540
549
  # Every result must render a headline (no KeyError on a new verdict).
541
550
  for result in codes:
542
551
  outcome = wci.WatchOutcome(
File without changes
File without changes
File without changes