@openhands/extensions 0.29.0 → 0.30.0

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 (84) hide show
  1. package/.agents/skills/custom-codereview-guide.md +44 -1
  2. package/.claude-plugin/marketplace.json +7 -5
  3. package/.codex-plugin/marketplace.json +7 -5
  4. package/.github/workflows/build-vscode-app.yml +3 -3
  5. package/.github/workflows/check-extensions.yml +1 -1
  6. package/.github/workflows/tests.yml +1 -1
  7. package/.plugin/marketplace.json +7 -5
  8. package/.release-please-manifest.json +1 -1
  9. package/README.md +3 -3
  10. package/apps/vscode/README.md +8 -6
  11. package/apps/vscode/canvas-extension.json +11 -3
  12. package/apps/vscode/extension.js +60 -12
  13. package/apps/vscode/scripts/package.mjs +20 -74
  14. package/automations/bundle-index.js +9 -7
  15. package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
  16. package/automations/catalog/github-issue-triage/manifest.json +4 -3
  17. package/automations/catalog/github-pr-reviewer/manifest.json +5 -4
  18. package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
  19. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
  20. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
  21. package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
  22. package/integrations/catalog/atlassian-rovo.json +4 -3
  23. package/integrations/catalog/bitbucket.json +16 -1
  24. package/integrations/catalog/github.json +1 -0
  25. package/integrations/catalog/gitlab.json +1 -1
  26. package/marketplaces/openhands-extensions.json +7 -5
  27. package/package.json +1 -1
  28. package/plugins/pr-review/README.md +1 -0
  29. package/plugins/pr-review/scripts/prompt.py +44 -11
  30. package/plugins/runtime-api-configs/SKILL.md +1 -1
  31. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
  32. package/pyproject.toml +1 -1
  33. package/python/openhands_extensions/_version.py +1 -1
  34. package/skills/canvas-extension-api/references/backend-safety.md +3 -3
  35. package/skills/canvas-extension-api/references/v1-contract.md +7 -4
  36. package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
  37. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
  38. package/skills/github/scripts/agent_conversation.py +224 -8
  39. package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
  40. package/skills/github-issue-triage/README.md +73 -14
  41. package/skills/github-issue-triage/SKILL.md +26 -46
  42. package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
  43. package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
  44. package/skills/github-issue-triage/scripts/worker.py +153 -103
  45. package/skills/github-pr-reviewer/README.md +103 -1
  46. package/skills/github-pr-reviewer/SKILL.md +35 -8
  47. package/skills/github-pr-reviewer/references/state-schema.md +8 -0
  48. package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
  49. package/skills/github-pr-reviewer/scripts/main.py +163 -39
  50. package/skills/github-pr-reviewer/scripts/publication.py +193 -0
  51. package/skills/github-pr-reviewer/scripts/worker.py +297 -8
  52. package/skills/github-pr-reviewer/tests/test_main.py +73 -5
  53. package/skills/gitlab-issue-to-mr/README.md +10 -3
  54. package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
  55. package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
  56. package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
  57. package/skills/index.js +8 -8
  58. package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
  59. package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
  60. package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
  61. package/skills/jira-issue-to-pr/README.md +18 -11
  62. package/skills/jira-issue-to-pr/SKILL.md +72 -23
  63. package/skills/jira-issue-to-pr/references/setup.md +43 -12
  64. package/skills/jira-issue-to-pr/scripts/main.py +136 -26
  65. package/skills/slack-channel-monitor/README.md +6 -1
  66. package/skills/slack-channel-monitor/SKILL.md +6 -3
  67. package/skills/slack-channel-monitor/scripts/main.py +36 -4
  68. package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
  69. package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
  70. package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
  71. package/skills/ticket-to-code-change/SKILL.md +5 -3
  72. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
  73. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  74. package/tests/test_agent_conversation_dispatch.py +173 -6
  75. package/tests/test_automation_llm_provenance.py +1209 -0
  76. package/tests/test_github_review_publication.py +422 -0
  77. package/tests/test_github_reviewer_delivery.py +174 -21
  78. package/tests/test_github_triage_delivery.py +411 -238
  79. package/tests/test_gitlab_issue_to_mr.py +284 -0
  80. package/tests/test_jira_issue_to_pr.py +200 -6
  81. package/tests/test_pr_review_prompt_injection.py +353 -0
  82. package/tests/test_slack_channel_monitor.py +32 -1
  83. package/tests/test_vscode_app.py +8 -2
  84. package/tests/vscode-app.test.mjs +8 -2
@@ -1,16 +1,75 @@
1
1
  # GitHub issue triage
2
2
 
3
- Prioritize open issues and establish acceptance criteria before marking them ready for development.
4
-
5
- Schedule this automation independently and select its agent profile. The profile
6
- chooses the model, tools, and GitHub credential; the PAT needs Issues write access
7
- only. Explicit issue dependencies must be completed before development starts.
8
- The agent checks repository guidance and adjacent code, preserves the author's
9
- issue description, and maintains accepted criteria in one clearly marked section
10
- at the end of the issue body. This lets repository readiness checks evaluate the
11
- same source that developers and reviewers use. When a material design decision
12
- remains unclear after repository research, the agent leaves the body unchanged,
13
- asks focused follow-up questions in a marked comment, and withholds the readiness
14
- label.
15
-
16
- See [SKILL.md](SKILL.md) for configuration and the catalog bundle for its files.
3
+ Prioritize issues and define observable acceptance criteria using
4
+ [SKILL.md](SKILL.md). The scanner honors dependencies and sends changed issues
5
+ to conversations using the selected profile.
6
+
7
+ ## Publication modes
8
+
9
+ Legacy agent publication is the default. The optional `triage_publication.py`
10
+ helper validates JSON recommendations and checks human inputs before every write.
11
+ It preserves text outside managed markers and edits/deletes only triage-marked
12
+ comments from the authenticated account. It skips identical writes and stops on
13
+ ambiguous markers. GitHub Actions readiness comments provide context, but neither
14
+ change the human-input fingerprint nor override policy.
15
+
16
+ This opt-in is **not credential isolation**: the agent can still write directly.
17
+ Enforced isolation needs a separate trusted publisher, read-only agent credentials,
18
+ and runtime support for passing results to the publisher.
19
+
20
+ ## Operator setup
21
+
22
+ Bundle `worker.py`, `triage_publication.py`, `github_client.py`, and
23
+ `agent_conversation.py`. Entrypoint: `python3 worker.py`.
24
+
25
+ 1. Place the publisher, `github_client.py`, and protected config in the delegated
26
+ workspace. Set `triage_publisher_path` and `triage_publisher_config_path` to
27
+ absolute file paths. Set both or neither; neither keeps legacy mode.
28
+ 2. Set `triage_publisher_workspace: "shared-host"` only after checking manually
29
+ that the agent can access those paths. The flag asserts access; it does not
30
+ prove sandbox visibility. The scanner checks files and the repository allowlist before dispatch, then
31
+ loads readiness policies from that same protected publisher config for its
32
+ input fingerprints. Do not duplicate policies in the scanner config. Remote/container paths are unsupported. If the
33
+ agent cannot invoke the helper, stop; do not fall back to direct writes.
34
+ 3. Cloud conversations are unsupported: bundling does not provision their files,
35
+ and the dispatcher cannot retrieve structured results from them.
36
+
37
+ Publisher config uses existing `repos` and optional `triage_readiness_policies`:
38
+
39
+ ```json
40
+ {
41
+ "repos": ["owner/repo"],
42
+ "triage_readiness_policies": {
43
+ "owner/repo": {
44
+ "mode": "authorized-writers",
45
+ "files": {".github/workflows/issue-readiness-check.yml": "<audited GitHub blob SHA>"}
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ Audit current policy and pin every relevant file to its actual 40-character
52
+ GitHub blob SHA (the file-content identifier). Only writer-only policies are
53
+ supported: the helper checks current SHAs and live `write`/`maintain`/`admin`
54
+ permission. Never select this mode for a policy that checks issue content.
55
+ Unknown, changed, or unsupported policies block new readiness grants, preserve
56
+ existing labels, and still allow criteria publication. No repository checker runs.
57
+
58
+ Precreate `ready-for-dev` and `priority:low/medium/high` labels. Use the same
59
+ GitHub account for scanner and publisher. Its fine-grained PAT needs selected-repo
60
+ Issues read/write, Contents read, and permission lookup access. Never put token
61
+ values in prompts or automation definitions.
62
+
63
+ ## Races and retries
64
+
65
+ - Checks and writes are not atomic: people or other automations can change inputs
66
+ between GET and PATCH. There is no shared lock or all-or-nothing update across
67
+ issue bodies, comments, and labels. Changed inputs stop publication; the next
68
+ scan recomputes the recommendation.
69
+ - The input fingerprint includes human readiness-label changes from the issue
70
+ timeline, but excludes the publisher's and GitHub Actions' label changes.
71
+ - A completion receipt is saved only after publication finishes. Partial writes
72
+ change the guarded-mode delivery key, allowing retries. If an unchanged failure
73
+ is marked finished by the dispatcher, manually retry the same command.
74
+ - Configured policy changes affect the fingerprint. Upstream policy changes alone
75
+ do not automatically retriage unchanged issues with managed markers.
@@ -9,49 +9,29 @@ triggers:
9
9
 
10
10
  Prioritize open issues and establish acceptance criteria before marking them ready for development.
11
11
 
12
- Create this automation separately from implementation and review. Use a
13
- fine-grained GitHub PAT limited to the selected repositories with Issues: read and
14
- write. Never put the token value in the automation definition or prompt.
15
-
16
- Package `worker.py` with the shared `github_client.py` and
17
- `agent_conversation.py` helpers. Supply `config.json` with `repos` and the saved
18
- GitHub secret name; the entrypoint is `python3 worker.py`. Include that secret in
19
- the selected profile so the delegated agent can use GitHub. Naming it in the
20
- automation does not grant it to the agent.
21
-
22
- The scanner is an ordinary Automation host command. It submits selected issues
23
- through the shared KV-backed conversation dispatcher, which creates or resumes
24
- one stable conversation per issue through the Software Agent SDK. Only that
25
- agent workspace is local or Docker. Automation owns scheduling, cancellation,
26
- and cleanup.
27
-
28
- Honor `Depends on: #12, #13` lines. A dependency must be closed as completed.
29
- Do not treat an existing `ready-for-dev` label as proof of readiness: inspect the
30
- issue discussion, repository instructions, and the closest relevant or adjacent
31
- implementation. Resolve reasonable ambiguity with a bounded scope and explicit
32
- non-goals. If a material product or design decision still cannot be inferred
33
- safely, ask only the focused follow-up questions needed to resolve it and withhold
34
- `ready-for-dev`; do not invent acceptance criteria around an arbitrary choice.
35
-
36
- Acceptance criteria must be observable and sufficient for a reviewer to decide
37
- that the requested behavior is complete. Check the main behavior and every
38
- applicable boundary: failures and edge cases, compatibility or migration,
39
- lifecycle and cleanup, permissions and secrets, user-facing documentation, and
40
- realistic automated or live validation. Avoid subjective criteria and avoid
41
- prescribing an implementation unless repository policy requires one mechanism.
42
-
43
- For an issue that is ready, preserve the author's description and maintain one
44
- clearly marked OpenHands AI section at the end of the issue body. Put the triage
45
- scope, any missing repository-required readiness sections, and testable
46
- acceptance criteria there. Replace only that marked section on later runs and do
47
- not also post a triage comment. Add `ready-for-dev` only after the final criteria
48
- pass this standard.
49
-
50
- If the design is still unclear, remove any stale managed body section, leave the
51
- human-owned body unchanged, and post the minimum focused questions in one marked
52
- comment below the human discussion. Withhold `ready-for-dev`. Preserve unrelated
53
- labels and never edit or delete human-authored text.
54
- Do not implement code or accept pull requests.
55
-
56
- Each scheduled run submits every changed eligible issue. A failure on one issue is
57
- reported and does not prevent the remaining issues from being submitted.
12
+ ## Setup
13
+
14
+ Keep triage separate from implementation and review. Follow [README.md](README.md)
15
+ for packaging, credentials, publisher setup, and limits. Never put token values
16
+ in the automation definition or prompt. Legacy agent publication is the default.
17
+
18
+ ## Triage instructions
19
+
20
+ - Honor explicit dependencies. Read applicable root/local `AGENTS.md`,
21
+ `.agents/skills/custom-codereview-guide.md` if present, human discussion, and
22
+ adjacent code. Identify affected versions and the owning component (Canvas,
23
+ SDK, automation, or extensions).
24
+ - Follow current human decisions over stale generated criteria. Do not repeat
25
+ answered questions. Keep scope bounded, name non-goals, and ask only unresolved
26
+ material product/design questions.
27
+ - Define observable completion, not entry requirements. Do not require tests for
28
+ unwritten code, PR artifacts, or images/videos for every issue. Default priority
29
+ to low; medium/high need recorded real user pain, not speculation or synthetic
30
+ tests.
31
+ - Return structured recommendations. If a publisher is configured, use it; never
32
+ bypass it or its changed-input checks. Legacy direct mode follows the same rules
33
+ without enforcement.
34
+ - Preserve human text and unrelated labels. Never run repository checkers with
35
+ credentials. Use current repository policy audited by the deployment owner, not
36
+ historical checker comments, to decide readiness.
37
+ - Do not implement code or accept pull requests.
@@ -3,7 +3,9 @@
3
3
  import json
4
4
  import os
5
5
  import time
6
+ from collections.abc import Callable
6
7
  from urllib.error import HTTPError
8
+ from urllib.parse import urlencode
7
9
  from urllib.request import Request, urlopen
8
10
  from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
9
11
 
@@ -113,6 +115,10 @@ class CloudConversations:
113
115
  },
114
116
  )
115
117
 
118
+ def agent_profiles(self) -> list[dict]:
119
+ listing = self._request("GET", "/api/agent-profiles")
120
+ return (listing or {}).get("profiles") or []
121
+
116
122
  def run(self, conversation: dict) -> None:
117
123
  """Start the next turn of an idle conversation on its own Agent Server."""
118
124
  try:
@@ -152,6 +158,17 @@ def _user_message(text: str) -> dict:
152
158
  return {"role": "user", "content": [{"type": "text", "text": text}]}
153
159
 
154
160
 
161
+ def _render(prompt, conversation_id) -> str:
162
+ """The text of `prompt` for the conversation it is delivered to.
163
+
164
+ A caller that must name the receiving conversation in its own prompt (a
165
+ marker the deterministic side later looks for, say) passes a function of
166
+ the conversation id, because only delivery decides which conversation that
167
+ is.
168
+ """
169
+ return prompt(str(conversation_id)) if callable(prompt) else prompt
170
+
171
+
155
172
  class AgentConversationDispatcher:
156
173
  """Deliver one revision at a time to a stable conversation for each subject."""
157
174
 
@@ -189,7 +206,11 @@ class AgentConversationDispatcher:
189
206
  return self._workspace.__exit__(*args)
190
207
 
191
208
  def deliver(
192
- self, subject: str, delivery: str, prompt: str, head: str = ""
209
+ self,
210
+ subject: str,
211
+ delivery: str,
212
+ prompt: str | Callable[[str], str],
213
+ head: str = "",
193
214
  ) -> dict[str, str]:
194
215
  """Deliver one revision of `subject`, deduping a repeated head.
195
216
 
@@ -202,6 +223,7 @@ class AgentConversationDispatcher:
202
223
  A conversation already running for this head is reported `in_progress`
203
224
  and left alone, whatever the new delivery says. Callers with no revision
204
225
  identity leave `head` empty and keep the delivery-key-only behavior.
226
+ `prompt` may be a function of the receiving conversation's id.
205
227
  """
206
228
  conversation_id = uuid5(NAMESPACE_URL, f"{self.automation_id}:{subject}")
207
229
  state_key = f"{_CONVERSATION_KEY_PREFIX}{conversation_id}"
@@ -252,6 +274,67 @@ class AgentConversationDispatcher:
252
274
  "conversation_id": str(conversation_id),
253
275
  }
254
276
 
277
+ def subject_conversation(self, subject: str) -> str | None:
278
+ """The conversation this automation currently delivers `subject` to."""
279
+ conversation_id = uuid5(NAMESPACE_URL, f"{self.automation_id}:{subject}")
280
+ record = _kv_request(f"{_CONVERSATION_KEY_PREFIX}{conversation_id}", "GET")
281
+ return (record or {}).get("conversation_id") or None
282
+
283
+ def llm_provenance(self, conversation_id: str) -> tuple[str, str] | None:
284
+ """The LLM profile name and model a conversation runs, or None if it is gone.
285
+
286
+ Both are read back from the conversation, never from the automation's
287
+ current settings: the server resolves the agent profile when it starts
288
+ the conversation, and a reused conversation keeps that LLM after the
289
+ profile is edited. The model is the conversation's own. The LLM profile
290
+ name comes from the launching agent profile only while that profile is
291
+ still at its launch revision, and is `unknown` otherwise.
292
+ """
293
+ if self._cloud:
294
+ conversation = self._cloud.get(conversation_id)
295
+ if conversation is None:
296
+ return None
297
+ model = conversation.get("llm_model")
298
+ else:
299
+ try:
300
+ conversation = self._agent_server_get(
301
+ f"/api/conversations/{conversation_id}"
302
+ )
303
+ except HTTPError as exc:
304
+ if exc.code == 404:
305
+ return None
306
+ raise
307
+ agent = conversation.get("agent") or {}
308
+ model = conversation.get("current_model_id")
309
+ if model is None and agent.get("kind") != "ACPAgent":
310
+ model = (agent.get("llm") or {}).get("model")
311
+ launched = conversation.get("launched_agent_profile") or {}
312
+ profile = "unknown"
313
+ if launched.get("agent_profile_id"):
314
+ try:
315
+ if self._cloud:
316
+ profiles = self._cloud.agent_profiles()
317
+ else:
318
+ listing = self._agent_server_get("/api/agent-profiles")
319
+ profiles = (listing or {}).get("profiles") or []
320
+ except Exception as exc: # noqa: BLE001 - the model is still known
321
+ print(f"Could not read agent profiles: {type(exc).__name__}: {exc}")
322
+ profiles = []
323
+ for item in profiles:
324
+ if str(item.get("id")) == str(launched["agent_profile_id"]):
325
+ if item.get("revision") == launched.get("revision"):
326
+ profile = item.get("llm_profile_ref") or "unknown"
327
+ break
328
+ return profile, model or "unknown"
329
+
330
+ def _agent_server_get(self, path: str) -> dict:
331
+ request = Request(
332
+ f"{self.agent_url.rstrip('/')}{path}",
333
+ headers={"X-Session-API-Key": self.api_key},
334
+ )
335
+ with urlopen(request, timeout=90) as response:
336
+ return json.load(response)
337
+
255
338
  def _deliver_cloud(
256
339
  self,
257
340
  record,
@@ -287,7 +370,9 @@ class AgentConversationDispatcher:
287
370
 
288
371
  if conversation is None:
289
372
  new_id = uuid4() if record else conversation_id
290
- self._cloud.start(new_id, self.profile_id, subject, prompt)
373
+ self._cloud.start(
374
+ new_id, self.profile_id, subject, _render(prompt, new_id)
375
+ )
291
376
  return "created", new_id
292
377
 
293
378
  status = conversation.get("execution_status")
@@ -303,10 +388,10 @@ class AgentConversationDispatcher:
303
388
  # The API reports an execution status only while the sandbox
304
389
  # runs, so an errored conversation whose sandbox was already
305
390
  # paused is not seen here and keeps reporting deduplicated.
306
- self._cloud.send(conversation, prompt)
391
+ self._cloud.send(conversation, _render(prompt, current_id))
307
392
  return "retried", current_id
308
393
  return "deduplicated", current_id
309
- self._cloud.send(conversation, prompt)
394
+ self._cloud.send(conversation, _render(prompt, current_id))
310
395
  return "resumed", current_id
311
396
 
312
397
  def _deliver_local(
@@ -326,12 +411,13 @@ class AgentConversationDispatcher:
326
411
  conversation = RemoteConversation.create(
327
412
  self._workspace,
328
413
  StartConversationRequest(
329
- workspace=LocalWorkspace(working_dir="/workspace"),
414
+ workspace=LocalWorkspace(working_dir=self._workspace.working_dir),
330
415
  conversation_id=conversation_id,
331
416
  agent_profile_id=self.profile_id,
332
417
  secrets=self._secrets,
333
418
  initial_message=SendMessageRequest(
334
- content=[TextContent(text=prompt)], run=True
419
+ content=[TextContent(text=_render(prompt, conversation_id))],
420
+ run=True,
335
421
  ),
336
422
  ),
337
423
  visualizer=None,
@@ -365,7 +451,7 @@ class AgentConversationDispatcher:
365
451
  # the work finished, so send the revision again rather
366
452
  # than strand it.
367
453
  conversation.update_secrets(self._secrets)
368
- conversation.send_message(prompt)
454
+ conversation.send_message(_render(prompt, conversation_id))
369
455
  conversation.run(blocking=False)
370
456
  disposition = "retried"
371
457
  else:
@@ -375,8 +461,138 @@ class AgentConversationDispatcher:
375
461
  # work on the same subject: reuse the conversation and send
376
462
  # the revision as its next turn.
377
463
  conversation.update_secrets(self._secrets)
378
- conversation.send_message(prompt)
464
+ conversation.send_message(_render(prompt, conversation_id))
379
465
  conversation.run(blocking=False)
380
466
  finally:
381
467
  conversation.close()
382
468
  return disposition
469
+
470
+ def review_snapshot(self, conversation_id):
471
+ if self._cloud:
472
+ conversation = self._cloud.get(conversation_id)
473
+ if conversation is None:
474
+ return "missing", ""
475
+ status = conversation.get("execution_status", "unknown")
476
+ if status != "finished":
477
+ return status, ""
478
+ result = self._cloud._request(
479
+ "GET",
480
+ f"{conversation['conversation_url']}/agent_final_response",
481
+ headers={"X-Session-API-Key": conversation["session_api_key"]},
482
+ )
483
+ return status, result.get("response", "")
484
+ headers = {"X-Session-API-Key": self.api_key}
485
+ base = f"{self.agent_url.rstrip('/')}/api/conversations/{conversation_id}"
486
+ try:
487
+ with urlopen(Request(base, headers=headers), timeout=90) as response:
488
+ status = json.load(response).get("execution_status", "unknown")
489
+ except HTTPError as exc:
490
+ if exc.code == 404:
491
+ return "missing", ""
492
+ raise
493
+ if status != "finished":
494
+ return status, ""
495
+ with urlopen(
496
+ Request(base + "/agent_final_response", headers=headers), timeout=90
497
+ ) as response:
498
+ return status, json.load(response).get("response", "")
499
+
500
+ def start_review_work(self, conversation_id, subject, prompt):
501
+ if self._cloud:
502
+ self._cloud.start(UUID(conversation_id), self.profile_id, subject, prompt)
503
+ return
504
+ if self._workspace is None:
505
+ raise RuntimeError("Dispatcher must be used as a context")
506
+ conversation = RemoteConversation.create(
507
+ self._workspace,
508
+ StartConversationRequest(
509
+ workspace=LocalWorkspace(working_dir=self._workspace.working_dir),
510
+ conversation_id=UUID(conversation_id),
511
+ agent_profile_id=self.profile_id,
512
+ secrets=self._secrets,
513
+ initial_message=SendMessageRequest(
514
+ content=[TextContent(text=prompt)], run=True
515
+ ),
516
+ ),
517
+ visualizer=None,
518
+ )
519
+ conversation.close()
520
+
521
+
522
+ class ReviewCoordination:
523
+ """CAS against the automation service's shared, user-authorized KV namespace."""
524
+
525
+ def __init__(self):
526
+ namespace = os.environ.get("REVIEW_COORDINATION_AUTOMATION_ID")
527
+ key = os.environ.get("REVIEW_COORDINATION_API_KEY")
528
+ if not namespace or not key:
529
+ raise RuntimeError(
530
+ "Structured reviews require shared coordination namespace and API key"
531
+ )
532
+ self.query = urlencode({"automation_id": str(UUID(namespace))})
533
+ self.base = os.environ["AUTOMATION_API_URL"].rstrip("/")
534
+ self.headers = {"X-Session-API-Key": key, "Content-Type": "application/json"}
535
+
536
+ def request(self, key, method="GET", value=None, condition=""):
537
+ request = Request(
538
+ f"{self.base}/v1/kv/{key}?{self.query}&{condition}",
539
+ data=None if value is None else json.dumps(value).encode(),
540
+ headers=self.headers,
541
+ method=method,
542
+ )
543
+ try:
544
+ with urlopen(request, timeout=90) as response:
545
+ return json.load(response)
546
+ except HTTPError as exc:
547
+ if method == "GET" and exc.code == 404:
548
+ return None
549
+ raise
550
+
551
+ def claim(self, subject, *, existing_only=False):
552
+ key = "review-work-" + str(uuid5(NAMESPACE_URL, subject))
553
+ for _ in range(5):
554
+ current = self.request(key, condition="meta=true")
555
+ if current is None and existing_only:
556
+ return None
557
+ value = (current or {}).get("value") or {}
558
+ if value.get("lease_until", 0) > time.time():
559
+ return None
560
+ value = {**value, "owner": str(uuid4()), "lease_until": time.time() + 180}
561
+ condition = f"if_version={current['version']}" if current else "nx=true"
562
+ try:
563
+ self.request(key, "PUT", value, condition)
564
+ return ReviewLease(self, key, value)
565
+ except HTTPError as exc:
566
+ if exc.code != 409:
567
+ raise
568
+ return None
569
+
570
+
571
+ class ReviewLease:
572
+ def __init__(self, store, key, value):
573
+ self.store, self.key, self.value = store, key, value
574
+ self.owner = value["owner"]
575
+
576
+ def save(self, **changes):
577
+ for _ in range(5):
578
+ current = self.store.request(self.key, condition="meta=true")
579
+ value = current["value"]
580
+ if (
581
+ value.get("owner") != self.owner
582
+ or value.get("lease_until", 0) <= time.time()
583
+ ):
584
+ raise RuntimeError("Review lease lost; refusing side effects")
585
+ updated = {**self.value, **changes}
586
+ try:
587
+ self.store.request(
588
+ self.key, "PUT", updated, f"if_version={current['version']}"
589
+ )
590
+ self.value = updated
591
+ return
592
+ except HTTPError as exc:
593
+ if exc.code != 409:
594
+ raise
595
+ raise RuntimeError("Review coordination contention; retry next scan")
596
+
597
+ def release(self):
598
+ self.save(lease_until=0)