sase-github 0.2.5__tar.gz → 0.2.7__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 (45) hide show
  1. sase_github-0.2.7/.release-please-manifest.json +3 -0
  2. {sase_github-0.2.5 → sase_github-0.2.7}/CHANGELOG.md +20 -0
  3. {sase_github-0.2.5 → sase_github-0.2.7}/PKG-INFO +1 -1
  4. {sase_github-0.2.5 → sase_github-0.2.7}/pyproject.toml +1 -1
  5. sase_github-0.2.7/src/sase_github/scripts/gh_setup.py +280 -0
  6. {sase_github-0.2.5 → sase_github-0.2.7}/src/sase_github/workspace_plugin.py +32 -21
  7. {sase_github-0.2.5 → sase_github-0.2.7}/src/sase_github/xprompts/gh.yml +3 -0
  8. sase_github-0.2.7/tests/test_gh_workspace_claims.py +950 -0
  9. {sase_github-0.2.5 → sase_github-0.2.7}/tests/test_workspace_plugin.py +29 -11
  10. sase_github-0.2.5/.release-please-manifest.json +0 -3
  11. sase_github-0.2.5/src/sase_github/scripts/gh_setup.py +0 -78
  12. {sase_github-0.2.5 → sase_github-0.2.7}/.github/workflows/ci.yml +0 -0
  13. {sase_github-0.2.5 → sase_github-0.2.7}/.github/workflows/pr-title.yml +0 -0
  14. {sase_github-0.2.5 → sase_github-0.2.7}/.github/workflows/publish.yml +0 -0
  15. {sase_github-0.2.5 → sase_github-0.2.7}/.sase_beads/beads.db +0 -0
  16. {sase_github-0.2.5 → sase_github-0.2.7}/.sase_beads/config.json +0 -0
  17. {sase_github-0.2.5 → sase_github-0.2.7}/.sase_beads/issues.jsonl +0 -0
  18. {sase_github-0.2.5 → sase_github-0.2.7}/CLAUDE.md +0 -0
  19. {sase_github-0.2.5 → sase_github-0.2.7}/Justfile +0 -0
  20. {sase_github-0.2.5 → sase_github-0.2.7}/LICENSE +0 -0
  21. {sase_github-0.2.5 → sase_github-0.2.7}/README.md +0 -0
  22. {sase_github-0.2.5 → sase_github-0.2.7}/docs/architecture.md +0 -0
  23. {sase_github-0.2.5 → sase_github-0.2.7}/docs/configuration.md +0 -0
  24. {sase_github-0.2.5 → sase_github-0.2.7}/docs/xprompts.md +0 -0
  25. {sase_github-0.2.5 → sase_github-0.2.7}/release-please-config.json +0 -0
  26. {sase_github-0.2.5 → sase_github-0.2.7}/src/sase_github/__init__.py +0 -0
  27. {sase_github-0.2.5 → sase_github-0.2.7}/src/sase_github/config.py +0 -0
  28. {sase_github-0.2.5 → sase_github-0.2.7}/src/sase_github/default_config.yml +0 -0
  29. {sase_github-0.2.5 → sase_github-0.2.7}/src/sase_github/errors.py +0 -0
  30. {sase_github-0.2.5 → sase_github-0.2.7}/src/sase_github/plugin.py +0 -0
  31. {sase_github-0.2.5 → sase_github-0.2.7}/src/sase_github/scripts/__init__.py +0 -0
  32. {sase_github-0.2.5 → sase_github-0.2.7}/src/sase_github/scripts/new_pr_desc_get_context.py +0 -0
  33. {sase_github-0.2.5 → sase_github-0.2.7}/src/sase_github/task_types.py +0 -0
  34. {sase_github-0.2.5 → sase_github-0.2.7}/src/sase_github/xprompts/new_pr_desc.yml +0 -0
  35. {sase_github-0.2.5 → sase_github-0.2.7}/src/sase_github/xprompts/pr_diff.yml +0 -0
  36. {sase_github-0.2.5 → sase_github-0.2.7}/src/sase_github/xprompts/prdd.yml +0 -0
  37. {sase_github-0.2.5 → sase_github-0.2.7}/tests/__init__.py +0 -0
  38. {sase_github-0.2.5 → sase_github-0.2.7}/tests/test_ci_install_contract.py +0 -0
  39. {sase_github-0.2.5 → sase_github-0.2.7}/tests/test_config.py +0 -0
  40. {sase_github-0.2.5 → sase_github-0.2.7}/tests/test_github_issues.py +0 -0
  41. {sase_github-0.2.5 → sase_github-0.2.7}/tests/test_github_plugin.py +0 -0
  42. {sase_github-0.2.5 → sase_github-0.2.7}/tests/test_github_pull_requests.py +0 -0
  43. {sase_github-0.2.5 → sase_github-0.2.7}/tests/test_submit_with_recorded_pr.py +0 -0
  44. {sase_github-0.2.5 → sase_github-0.2.7}/tests/test_task_types.py +0 -0
  45. {sase_github-0.2.5 → sase_github-0.2.7}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.2.7"
3
+ }
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.7](https://github.com/sase-org/sase-github/compare/v0.2.6...v0.2.7) (2026-08-28)
4
+
5
+
6
+ ### Features
7
+
8
+ * **gh-setup:** adopt the runner's numbered workspace claim ([5e8e9ea](https://github.com/sase-org/sase-github/commit/5e8e9ea6a48b5b285a65d9cb1fa087f74d52b6b0))
9
+
10
+ ## [0.2.6](https://github.com/sase-org/sase-github/compare/v0.2.5...v0.2.6) (2026-08-18)
11
+
12
+
13
+ ### Features
14
+
15
+ * claim GitHub workspaces atomically ([61dd36f](https://github.com/sase-org/sase-github/commit/61dd36fa1aef7ec71475608e19de3ddf91b67b74))
16
+ * **workspace:** guard gh workflow steps against occupied checkouts ([51347d4](https://github.com/sase-org/sase-github/commit/51347d4f0a256c80785e7e2633fa589259073481))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **workspace:** release the gh setup claim when the occupancy guard refuses ([99a6277](https://github.com/sase-org/sase-github/commit/99a6277ecceb47d702c42351623ed27fbed11822))
22
+
3
23
  ## [0.2.5](https://github.com/sase-org/sase-github/compare/v0.2.4...v0.2.5) (2026-08-18)
4
24
 
5
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sase-github
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: GitHub VCS plugin for sase
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sase-github"
7
- version = "0.2.5"
7
+ version = "0.2.7"
8
8
  description = "GitHub VCS plugin for sase"
9
9
  requires-python = ">=3.12"
10
10
  license = "MIT"
@@ -0,0 +1,280 @@
1
+ """Setup step for the #gh xprompt workflow."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ from sase.core.occupancy_guard import OccupancyCaller, ensure_workspace_not_occupied
7
+ from sase.running_field import (
8
+ WorkspaceClaim,
9
+ claim_next_axe_workspace,
10
+ claim_workspace,
11
+ find_runner_numbered_workspace,
12
+ get_claimed_workspaces,
13
+ release_workspace,
14
+ )
15
+ from sase.sdd.store import materialize_sdd_store
16
+ from sase.workspace_provider import resolve_ref
17
+ from sase.workspace_provider.occupant import new_occupant_record, write_occupant_record
18
+ from sase.workspace_provider.utils import ensure_workspace_checkout
19
+
20
+ _CALLER_TAG = "gh-setup"
21
+
22
+
23
+ def main(
24
+ *,
25
+ gh_ref: str,
26
+ n: int | None,
27
+ release: bool,
28
+ workflow_label: str | None = None,
29
+ ) -> None:
30
+ """Resolve GitHub ref, claim a workspace, and print config.
31
+
32
+ Prints key=value output for the workflow executor.
33
+ """
34
+ resolved = resolve_ref(gh_ref, "gh")
35
+
36
+ project_name = resolved.project_name
37
+ project_file = resolved.project_file
38
+ workflow_name = workflow_label or f"gh-{gh_ref}"
39
+ cl_name = None if "/" in gh_ref else gh_ref
40
+
41
+ # Use the parent process PID, not our own. This setup step runs as a
42
+ # short-lived subprocess (via ``subprocess.run``), so ``os.getpid()``
43
+ # would die immediately after setup. ``stale_running_cleanup`` would
44
+ # then incorrectly remove the RUNNING entry while the real workflow
45
+ # (the parent process) is still alive.
46
+ pid = os.getppid()
47
+
48
+ # Skip claiming when pre-allocated — the launcher already claimed the workspace
49
+ pre_allocated = os.environ.get("SASE_GH_PRE_ALLOCATED") == "1"
50
+ if pre_allocated:
51
+ workspace_num = int(os.environ["SASE_GH_WORKSPACE_NUM"])
52
+ workspace_dir = os.environ["SASE_GH_WORKSPACE_DIR"]
53
+ materialize_sdd_store(workspace_dir, workspace_num)
54
+ elif n is not None:
55
+ workspace_num, workspace_dir = _claim_pinned_workspace(
56
+ project_file=project_file,
57
+ primary_workspace_dir=resolved.primary_workspace_dir,
58
+ workspace_num=n,
59
+ workflow_name=workflow_name,
60
+ pid=pid,
61
+ cl_name=cl_name,
62
+ pinned=not release,
63
+ )
64
+ else:
65
+ adopted = _adopt_runner_workspace(
66
+ project_file=project_file,
67
+ primary_workspace_dir=resolved.primary_workspace_dir,
68
+ )
69
+ if adopted is not None:
70
+ workspace_num, workspace_dir = adopted
71
+ pre_allocated = True
72
+ else:
73
+ workspace_num, workspace_dir = _claim_next_workspace(
74
+ project_file=project_file,
75
+ primary_workspace_dir=resolved.primary_workspace_dir,
76
+ workflow_name=workflow_name,
77
+ pid=pid,
78
+ cl_name=cl_name,
79
+ pinned=not release,
80
+ )
81
+
82
+ # Refuse to hand this checkout to `prepare`/`checkout` if another live
83
+ # agent still occupies it. Runs on the pre_allocated branch too: the
84
+ # launcher already wrote its own occupant record, and the conflict
85
+ # decision recognises the caller's own lineage via the shared pid.
86
+ # A refusal here aborts the step, so the slot this run just claimed must
87
+ # go back — the workflow's `release` step never runs once `setup` fails.
88
+ # The pre_allocated branch claimed nothing, so it releases nothing.
89
+ try:
90
+ ensure_workspace_not_occupied(
91
+ workspace_dir,
92
+ project_file=project_file,
93
+ caller=OccupancyCaller(
94
+ pid=pid,
95
+ workspace_num=workspace_num,
96
+ project=project_name,
97
+ workflow=workflow_name,
98
+ ),
99
+ )
100
+ except Exception:
101
+ if not pre_allocated:
102
+ release_workspace(
103
+ project_file,
104
+ workspace_num,
105
+ workflow_name,
106
+ cl_name,
107
+ caller_tag=_CALLER_TAG,
108
+ )
109
+ raise
110
+
111
+ # Claim the checkout for this run. Workspace numbers 0/1 both mean
112
+ # "primary", never a real numbered workspace (see
113
+ # ``ensure_workspace_checkout``), and the pre_allocated branch's
114
+ # occupant record already belongs to the launcher.
115
+ if not pre_allocated and workspace_num > 1:
116
+ write_occupant_record(
117
+ workspace_dir,
118
+ new_occupant_record(
119
+ pid=pid,
120
+ workflow=workflow_name,
121
+ project=project_name,
122
+ workspace_num=workspace_num,
123
+ cl_name=cl_name,
124
+ ),
125
+ )
126
+
127
+ print(f"project_name={project_name}")
128
+ print(f"project_file={project_file}")
129
+ print(f"workspace_dir={workspace_dir}")
130
+ print(f"workspace_num={workspace_num}")
131
+ print(f"checkout_target={resolved.checkout_target}")
132
+ print(f"primary_workspace_dir={resolved.primary_workspace_dir}")
133
+ # Don't release pre-allocated workspaces — the launcher handles that
134
+ should_release = release and not pre_allocated
135
+ print(f"should_release={'true' if should_release else 'false'}")
136
+ print(f"_chdir={workspace_dir}")
137
+ print(f"meta_workspace={workspace_num}")
138
+ print(f"workflow_name={workflow_name}")
139
+
140
+
141
+ def _adopt_runner_workspace(
142
+ *,
143
+ project_file: str,
144
+ primary_workspace_dir: str,
145
+ ) -> tuple[int, str] | None:
146
+ """Reuse the calling runner's numbered pool claim, if it holds one."""
147
+ workspace_num = find_runner_numbered_workspace(project_file)
148
+ if workspace_num is None:
149
+ return None
150
+ workspace_dir = ensure_workspace_checkout(primary_workspace_dir, workspace_num)
151
+ materialize_sdd_store(workspace_dir, workspace_num)
152
+ return workspace_num, workspace_dir
153
+
154
+
155
+ def _claim_next_workspace(
156
+ *,
157
+ project_file: str,
158
+ primary_workspace_dir: str,
159
+ workflow_name: str,
160
+ pid: int,
161
+ cl_name: str | None,
162
+ pinned: bool,
163
+ ) -> tuple[int, str]:
164
+ """Atomically allocate a slot, then materialize the checkout."""
165
+ workspace_num = claim_next_axe_workspace(
166
+ project_file,
167
+ workflow_name,
168
+ pid,
169
+ cl_name=cl_name,
170
+ pinned=pinned,
171
+ caller_tag=_CALLER_TAG,
172
+ )
173
+ workspace_dir = _materialize_or_release(
174
+ project_file=project_file,
175
+ primary_workspace_dir=primary_workspace_dir,
176
+ workspace_num=workspace_num,
177
+ workflow_name=workflow_name,
178
+ cl_name=cl_name,
179
+ )
180
+ return workspace_num, workspace_dir
181
+
182
+
183
+ def _claim_pinned_workspace(
184
+ *,
185
+ project_file: str,
186
+ primary_workspace_dir: str,
187
+ workspace_num: int,
188
+ workflow_name: str,
189
+ pid: int,
190
+ cl_name: str | None,
191
+ pinned: bool,
192
+ ) -> tuple[int, str]:
193
+ """Claim a pinned ``n=<num>`` target once. Occupied slots fail with the occupant named."""
194
+ claim_result = claim_workspace(
195
+ project_file,
196
+ workspace_num,
197
+ workflow_name,
198
+ pid,
199
+ cl_name,
200
+ pinned=pinned,
201
+ caller_tag=_CALLER_TAG,
202
+ )
203
+ if not claim_result.success:
204
+ occupant = _describe_workspace_occupant(project_file, workspace_num)
205
+ if occupant is not None:
206
+ print(
207
+ f"Pinned workspace #{workspace_num} is already claimed by {occupant}",
208
+ file=sys.stderr,
209
+ )
210
+ else:
211
+ print(
212
+ f"Pinned workspace #{workspace_num} could not be claimed: "
213
+ f"{claim_result.error or 'unknown reason'}",
214
+ file=sys.stderr,
215
+ )
216
+ sys.exit(1)
217
+ workspace_dir = _materialize_or_release(
218
+ project_file=project_file,
219
+ primary_workspace_dir=primary_workspace_dir,
220
+ workspace_num=workspace_num,
221
+ workflow_name=workflow_name,
222
+ cl_name=cl_name,
223
+ )
224
+ return workspace_num, workspace_dir
225
+
226
+
227
+ def _materialize_or_release(
228
+ *,
229
+ project_file: str,
230
+ primary_workspace_dir: str,
231
+ workspace_num: int,
232
+ workflow_name: str,
233
+ cl_name: str | None,
234
+ ) -> str:
235
+ """Materialize after a successful claim; release the slot if setup fails."""
236
+ try:
237
+ workspace_dir = ensure_workspace_checkout(primary_workspace_dir, workspace_num)
238
+ materialize_sdd_store(workspace_dir, workspace_num)
239
+ except Exception:
240
+ release_workspace(
241
+ project_file,
242
+ workspace_num,
243
+ workflow_name,
244
+ cl_name,
245
+ caller_tag=_CALLER_TAG,
246
+ )
247
+ raise
248
+ return workspace_dir
249
+
250
+
251
+ def _describe_workspace_occupant(project_file: str, workspace_num: int) -> str | None:
252
+ occupants = [
253
+ claim
254
+ for claim in get_claimed_workspaces(project_file)
255
+ if claim.workspace_num == workspace_num
256
+ ]
257
+ if not occupants:
258
+ return None
259
+ return "; ".join(_format_workspace_occupant(claim) for claim in occupants)
260
+
261
+
262
+ def _format_workspace_occupant(claim: WorkspaceClaim) -> str:
263
+ name = claim.cl_name or claim.workflow
264
+ liveness = "live" if _pid_is_alive(claim.pid) else "dead"
265
+ detail = f"{name} (pid {claim.pid}, {liveness}, workflow {claim.workflow}"
266
+ if claim.artifacts_timestamp:
267
+ detail += f", artifacts {claim.artifacts_timestamp}"
268
+ return detail + ")"
269
+
270
+
271
+ def _pid_is_alive(pid: int) -> bool:
272
+ if pid <= 0:
273
+ return False
274
+ try:
275
+ os.kill(pid, 0)
276
+ except ProcessLookupError:
277
+ return False
278
+ except PermissionError:
279
+ return True
280
+ return True
@@ -487,10 +487,14 @@ class GitHubWorkspacePlugin:
487
487
  kill_and_persist_all_running_processes,
488
488
  )
489
489
  from sase.ace.operations import has_active_children
490
+ from sase.core.occupancy_guard import (
491
+ OccupancyCaller,
492
+ WorkspaceOccupiedError,
493
+ ensure_workspace_not_occupied,
494
+ )
490
495
  from sase.running_field import (
491
- claim_workspace,
492
- get_first_available_axe_workspace,
493
- get_workspace_directory_for_num,
496
+ WorkspaceClaimError,
497
+ claim_next_axe_workspace_dir,
494
498
  release_workspace,
495
499
  )
496
500
  from sase.vcs_provider import get_vcs_provider
@@ -536,36 +540,42 @@ class GitHubWorkspacePlugin:
536
540
  if not workspace_dir:
537
541
  return (False, "WORKSPACE_DIR is not set for this project")
538
542
 
539
- workspace_num = get_first_available_axe_workspace(patch_file)
540
543
  workflow_name = f"submit-{patch_name}"
541
- pid = os.getpid()
542
-
543
544
  try:
544
- ws_dir, _ = get_workspace_directory_for_num(workspace_num, project_basename)
545
- except RuntimeError as e:
546
- return (False, f"Failed to get workspace directory: {e}")
545
+ workspace_num, ws_dir, _ = claim_next_axe_workspace_dir(
546
+ patch_file,
547
+ workflow_name,
548
+ os.getpid(),
549
+ project_basename,
550
+ cl_name=patch_name,
551
+ caller_tag="gh-submit",
552
+ )
553
+ except WorkspaceClaimError as exc:
554
+ return (False, f"Failed to claim workspace: {exc}")
547
555
 
548
556
  if rich_console:
549
557
  rich_console.print(f"[cyan]Claiming workspace #{workspace_num}[/cyan]")
550
558
 
551
- if not claim_workspace(
552
- patch_file,
553
- workspace_num,
554
- workflow_name,
555
- pid,
556
- patch_name,
557
- ):
558
- return (
559
- False,
560
- f"Failed to claim workspace #{workspace_num}",
561
- )
562
-
563
559
  try:
564
560
  if rich_console:
565
561
  rich_console.print(
566
562
  f"[cyan]Checking out {escape_markup(patch_name)}...[/cyan]"
567
563
  )
568
564
 
565
+ try:
566
+ ensure_workspace_not_occupied(
567
+ ws_dir,
568
+ project_file=patch_file,
569
+ caller=OccupancyCaller(
570
+ pid=os.getpid(),
571
+ workspace_num=workspace_num,
572
+ project=project_basename,
573
+ workflow=workflow_name,
574
+ ),
575
+ )
576
+ except WorkspaceOccupiedError as exc:
577
+ return (False, f"Refusing checkout: {exc}")
578
+
569
579
  provider = get_vcs_provider(ws_dir)
570
580
  branch_name = provider.resolve_revision(
571
581
  patch_name, project_basename, ws_dir
@@ -619,6 +629,7 @@ class GitHubWorkspacePlugin:
619
629
  workspace_num,
620
630
  workflow_name,
621
631
  patch_name,
632
+ caller_tag="gh-submit",
622
633
  )
623
634
  if rich_console:
624
635
  rich_console.print(f"[cyan]Released workspace #{workspace_num}[/cyan]")
@@ -120,13 +120,16 @@ steps:
120
120
  if: "{{ setup.should_release }}"
121
121
  python: |
122
122
  from sase.running_field import release_workspace
123
+ from sase.workspace_provider.occupant import clear_occupant_record
123
124
 
124
125
  release_workspace(
125
126
  {{ setup.project_file | tojson }},
126
127
  {{ setup.workspace_num }},
127
128
  {{ setup.workflow_name | tojson }},
128
129
  {{ gh_ref | tojson }} if "/" not in {{ gh_ref | tojson }} else None,
130
+ caller_tag="gh-release",
129
131
  )
132
+ clear_occupant_record({{ setup.workspace_dir | tojson }})
130
133
  print("released=true")
131
134
  output: { released: bool }
132
135