-
Notifications
You must be signed in to change notification settings - Fork 3.1k
feat(loops): Run a skill instead of instructions #73069
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6f4f1e2
671a3d6
09ea81a
6fff546
f0dd93b
9830f1e
fab041d
ac2d02e
d0b3f70
ad48cc6
b6c8c73
09da4fe
a88e37e
632d5cd
225c9a5
525e4de
0ff61e2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -490,6 +490,150 @@ def _team_rate_capped(loop: Loop) -> bool: | |
| return fire_count >= LOOP_TEAM_RATE_CAP_PER_DAY | ||
|
|
||
|
|
||
| def _seed_skill_bundles_and_dispatch( | ||
| *, | ||
| task_run: TaskRun, | ||
| team_id: int, | ||
| user_id: int | None, | ||
| task_id: str, | ||
| run_id: str, | ||
| create_pr: bool, | ||
| posthog_mcp_scopes: PosthogMcpScopes, | ||
| ) -> None: | ||
| """Post-commit tail of a fire: seed the run's snapshotted skill bundles, then | ||
| dispatch the Temporal workflow. Seeding copies S3 objects, and | ||
| ``_fire_loop_committed`` holds the team-wide advisory lock for its whole transaction | ||
| — an external call under that lock would serialize every fire for the team behind S3 | ||
| latency (the same reason the usage gate runs before the lock and dispatch runs after | ||
| commit). Post-commit there is no rollback to lean on, so a failed seed compensates | ||
| instead: the run is terminalized as failed (feeding the loop's failure bookkeeping) | ||
| and the workflow is never dispatched, so the agent can't run with silently missing | ||
| skills.""" | ||
| try: | ||
| _seed_skill_bundle_artifacts(task_run) | ||
| except Exception as exc: | ||
| logger.exception( | ||
| "loop_run.skill_bundle_seed_failed", | ||
| extra={"task_run_id": run_id, "error": str(exc)}, | ||
| ) | ||
| from products.tasks.backend.temporal.client import ( # noqa: PLC0415 — breaks the temporal.client -> loop_runs import cycle | ||
| _terminalize_unstarted_task_run, | ||
| ) | ||
|
|
||
| _terminalize_unstarted_task_run(run_id, "Failed to stage the loop's skill bundles for this run") | ||
| return | ||
|
|
||
| _execute_task_processing_workflow_for_loop( | ||
| team_id=team_id, | ||
| user_id=user_id, | ||
| task_id=task_id, | ||
| run_id=run_id, | ||
| create_pr=create_pr, | ||
| posthog_mcp_scopes=posthog_mcp_scopes, | ||
| ) | ||
|
|
||
|
|
||
| def ensure_loop_skill_bundles_seeded(task_run: TaskRun) -> bool: | ||
| """Idempotently seed a loop run's skill bundles ahead of an out-of-band dispatch. | ||
|
|
||
| The orphaned-QUEUED reconciler recovers runs whose create-time ``on_commit`` callback | ||
| was lost — the same callback that seeds skill bundles — so re-dispatching without this | ||
| check would silently start the run with its skills missing. Seeds strictly from the | ||
| snapshot captured on the run at fire time, never from the live loop: the loop's | ||
| bundles (and its owner) may have changed since, and the run executes with the | ||
| credentials captured at fire time. Returns False when seeding was needed but failed; | ||
| the caller must not dispatch (the next sweep retries).""" | ||
| task = task_run.task | ||
| if task.origin_product != Task.OriginProduct.LOOP or task.loop_id is None: | ||
| return True | ||
| already_seeded = any( | ||
| isinstance(entry, dict) and entry.get("type") == "skill_bundle" for entry in (task_run.artifacts or []) | ||
| ) | ||
| if already_seeded: | ||
| return True | ||
| try: | ||
| _seed_skill_bundle_artifacts(task_run) | ||
| except Exception: | ||
| logger.exception( | ||
| "loop_run.skill_bundle_seed_failed", | ||
| extra={"task_run_id": str(task_run.id)}, | ||
| ) | ||
| return False | ||
| return True | ||
|
|
||
|
|
||
| def _snapshot_skill_bundle_seeds(loop: Loop) -> list[dict]: | ||
| """The bundle entries a fire freezes onto its run's state. Seeding always reads this | ||
| snapshot rather than the loop row, so a later replace or ownership takeover can't | ||
| swap which skills an already-created run installs.""" | ||
| return [entry for entry in (loop.skill_bundles or []) if isinstance(entry, dict) and entry.get("storage_path")] | ||
|
|
||
|
|
||
| def _seed_skill_bundle_artifacts(task_run: TaskRun) -> None: | ||
| """Copy the run's snapshotted skill bundles into place: S3 objects under the run's | ||
| artifact prefix plus matching ``skill_bundle`` manifest entries, so the sandbox | ||
| agent-server installs them exactly like bundles a client uploaded at task creation. | ||
| Raises on a failed copy; the caller compensates. Already-copied objects are deleted | ||
| best-effort before re-raising, since nothing would ever reference them.""" | ||
| state = task_run.state if isinstance(task_run.state, dict) else {} | ||
| seeds = state.get("skill_bundle_seeds") | ||
| bundles = [entry for entry in (seeds or []) if isinstance(entry, dict) and entry.get("storage_path")] | ||
| if not bundles: | ||
| return | ||
|
|
||
| from posthog.storage import object_storage # noqa: PLC0415 — keep storage deps off the fire path import | ||
|
|
||
| from products.tasks.backend.logic.services.staged_artifacts import get_safe_artifact_name # noqa: PLC0415 | ||
|
|
||
| # `state` is a client-PATCHable surface (the seeds key is server-protected, but this | ||
| # copy primitive must not trust it anyway): every source path has to sit under the | ||
| # owning loop's own bundle prefix, and the target segments are re-sanitized, or a | ||
| # forged entry would read or write arbitrary bucket keys via the recovery path. | ||
| loop_id = task_run.task.loop_id | ||
| if loop_id is None: | ||
| raise ValueError("skill bundle seeds on a run without a loop") | ||
| loop_prefix = f"{Loop.skill_bundle_s3_prefix_for(task_run.team_id, loop_id)}/" | ||
|
|
||
| run_prefix = task_run.get_artifact_s3_prefix() | ||
| manifest = list(task_run.artifacts or []) | ||
| copied_paths: list[str] = [] | ||
| try: | ||
| for bundle in bundles: | ||
| entry = dict(bundle) | ||
| source_path = str(entry["storage_path"]) | ||
| if not source_path.startswith(loop_prefix): | ||
| raise ValueError("skill bundle seed escapes the loop's storage prefix") | ||
| entry_id = str(entry["id"]) | ||
| if not re.fullmatch(r"[0-9a-f]{8,64}", entry_id): | ||
| raise ValueError("skill bundle seed has a malformed id") | ||
| entry["name"] = get_safe_artifact_name(str(entry["name"])) | ||
| target_path = f"{run_prefix}/{entry_id[:8]}_{entry['name']}" | ||
| object_storage.copy(source_path, target_path) | ||
| copied_paths.append(target_path) | ||
| try: | ||
| object_storage.tag(target_path, {"ttl_days": "30", "team_id": str(task_run.team_id)}) | ||
| except Exception as exc: | ||
| logger.warning( | ||
| "loop_run.skill_bundle_tag_failed", | ||
| extra={"task_run_id": str(task_run.id), "storage_path": target_path, "error": str(exc)}, | ||
| ) | ||
| entry["storage_path"] = target_path | ||
| manifest.append(entry) | ||
|
|
||
| task_run.artifacts = manifest | ||
| task_run.save(update_fields=["artifacts", "updated_at"]) | ||
| except Exception: | ||
| if copied_paths: | ||
| try: | ||
| object_storage.delete_objects(copied_paths) | ||
| except Exception as cleanup_exc: | ||
| logger.warning( | ||
| "loop_run.skill_bundle_seed_cleanup_failed", | ||
| extra={"task_run_id": str(task_run.id), "paths": copied_paths, "error": str(cleanup_exc)}, | ||
| ) | ||
| raise | ||
|
|
||
|
|
||
| def _create_loop_task_and_run(loop: Loop, trigger: LoopTrigger | None, trigger_context: str) -> tuple[Task, TaskRun]: | ||
| repository: str | None = None | ||
| github_integration_id: int | None = None | ||
|
|
@@ -571,6 +715,13 @@ def _create_loop_task_and_run(loop: Loop, trigger: LoopTrigger | None, trigger_c | |
| "slack_thread_context": None, | ||
| "workflow_id_prefix": None, | ||
| } | ||
| # Freeze the bundle set on the run itself: seeding (post-commit and reconciler | ||
| # recovery alike) reads only this snapshot, so a later replace or ownership takeover | ||
| # can't change which skills this run installs under its captured credentials. | ||
| bundle_seeds = _snapshot_skill_bundle_seeds(loop) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. High: Bundles persist across ownership takeover A malicious owner can attach a bundle and leave the loop enabled. If a teammate then takes ownership, this code snapshots the old bundle while creating the task with the new owner's credentials. A scheduled or teammate-triggered fire can therefore run attacker-authored skill instructions with the new owner's authority. Clear and expire bundles during takeover, or bind each manifest to its owner and refuse to seed it after ownership changes until the new owner explicitly approves or replaces it. |
||
| if bundle_seeds: | ||
| extra_state["skill_bundle_seeds"] = bundle_seeds | ||
|
|
||
| task_run = task.create_run(mode="background", extra_state=extra_state) | ||
|
|
||
| team_id = loop.team_id | ||
|
|
@@ -579,7 +730,8 @@ def _create_loop_task_and_run(loop: Loop, trigger: LoopTrigger | None, trigger_c | |
| run_id = str(task_run.id) | ||
|
|
||
| transaction.on_commit( | ||
| lambda: _execute_task_processing_workflow_for_loop( | ||
| lambda: _seed_skill_bundles_and_dispatch( | ||
| task_run=task_run, | ||
| team_id=team_id, | ||
| user_id=user_id, | ||
| task_id=task_id, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
| dependencies = [ | ||
| ("tasks", "0071_alter_origin_product_mcp_analytics"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AddField( | ||
| model_name="loop", | ||
| name="skill_bundles", | ||
| field=models.JSONField(blank=True, default=list), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| 0071_alter_origin_product_mcp_analytics | ||
| 0072_loop_skill_bundles |
Uh oh!
There was an error while loading. Please reload this page.