ags-cli 0.1.0__py3-none-any.whl

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 (156) hide show
  1. ags_cli-0.1.0.dist-info/METADATA +332 -0
  2. ags_cli-0.1.0.dist-info/RECORD +156 -0
  3. ags_cli-0.1.0.dist-info/WHEEL +5 -0
  4. ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ags_cli-0.1.0.dist-info/top_level.txt +2 -0
  6. cli/__init__.py +0 -0
  7. cli/__main__.py +4 -0
  8. cli/client.py +145 -0
  9. cli/commands/__init__.py +0 -0
  10. cli/commands/adapter.py +33 -0
  11. cli/commands/artifact.py +21 -0
  12. cli/commands/ask.py +212 -0
  13. cli/commands/chat.py +649 -0
  14. cli/commands/diff.py +49 -0
  15. cli/commands/doctor.py +93 -0
  16. cli/commands/gui.py +45 -0
  17. cli/commands/init.py +20 -0
  18. cli/commands/mcp.py +50 -0
  19. cli/commands/memory.py +65 -0
  20. cli/commands/model.py +73 -0
  21. cli/commands/policy.py +52 -0
  22. cli/commands/project.py +122 -0
  23. cli/commands/quota.py +107 -0
  24. cli/commands/run.py +84 -0
  25. cli/commands/serve.py +171 -0
  26. cli/commands/service.py +236 -0
  27. cli/commands/session.py +219 -0
  28. cli/commands/stats.py +96 -0
  29. cli/commands/status.py +36 -0
  30. cli/commands/task.py +37 -0
  31. cli/commands/usage.py +86 -0
  32. cli/local.py +84 -0
  33. cli/main.py +149 -0
  34. harness/__init__.py +4 -0
  35. harness/adapters/__init__.py +26 -0
  36. harness/adapters/antigravity.py +301 -0
  37. harness/adapters/claude_code.py +522 -0
  38. harness/adapters/local_openai.py +534 -0
  39. harness/adapters/mock.py +112 -0
  40. harness/adapters/probe.py +65 -0
  41. harness/adapters/registry.py +56 -0
  42. harness/adapters/warm_pool.py +255 -0
  43. harness/api/__init__.py +0 -0
  44. harness/api/router.py +38 -0
  45. harness/api/v1/__init__.py +0 -0
  46. harness/api/v1/adapters.py +105 -0
  47. harness/api/v1/approvals.py +173 -0
  48. harness/api/v1/artifacts.py +44 -0
  49. harness/api/v1/attachments.py +48 -0
  50. harness/api/v1/doctor.py +22 -0
  51. harness/api/v1/health.py +37 -0
  52. harness/api/v1/mcp.py +131 -0
  53. harness/api/v1/memory.py +80 -0
  54. harness/api/v1/policies.py +49 -0
  55. harness/api/v1/project.py +302 -0
  56. harness/api/v1/runs.py +98 -0
  57. harness/api/v1/sessions.py +248 -0
  58. harness/api/v1/stream.py +110 -0
  59. harness/api/v1/tasks.py +30 -0
  60. harness/api/v1/usage.py +58 -0
  61. harness/bootstrap.py +216 -0
  62. harness/db.py +164 -0
  63. harness/deps.py +58 -0
  64. harness/eventbus/__init__.py +20 -0
  65. harness/eventbus/inprocess_bus.py +85 -0
  66. harness/main.py +144 -0
  67. harness/mcp/__init__.py +22 -0
  68. harness/mcp/client.py +139 -0
  69. harness/mcp/config_gen.py +77 -0
  70. harness/mcp/registry.py +156 -0
  71. harness/mcp/tools.py +81 -0
  72. harness/memory/__init__.py +13 -0
  73. harness/memory/context_budget.py +116 -0
  74. harness/memory/embed.py +217 -0
  75. harness/memory/extract.py +74 -0
  76. harness/memory/ingest.py +106 -0
  77. harness/memory/namespaces.py +57 -0
  78. harness/memory/ranking.py +31 -0
  79. harness/memory/redact.py +37 -0
  80. harness/memory/service.py +320 -0
  81. harness/memory/sqlite_vec_backend.py +266 -0
  82. harness/memory/summarize.py +68 -0
  83. harness/models/__init__.py +35 -0
  84. harness/models/adapter_health.py +27 -0
  85. harness/models/approval.py +37 -0
  86. harness/models/artifact.py +35 -0
  87. harness/models/audit_log.py +33 -0
  88. harness/models/comparison.py +33 -0
  89. harness/models/enums.py +146 -0
  90. harness/models/mcp_server_config.py +49 -0
  91. harness/models/memory.py +76 -0
  92. harness/models/policy.py +29 -0
  93. harness/models/provider_config.py +35 -0
  94. harness/models/run.py +46 -0
  95. harness/models/run_event.py +35 -0
  96. harness/models/session.py +50 -0
  97. harness/models/task.py +30 -0
  98. harness/models/types.py +63 -0
  99. harness/models/workspace.py +27 -0
  100. harness/observability/__init__.py +4 -0
  101. harness/observability/audit.py +88 -0
  102. harness/observability/logging.py +47 -0
  103. harness/observability/metrics.py +43 -0
  104. harness/observability/tracing.py +38 -0
  105. harness/orchestrator/__init__.py +19 -0
  106. harness/orchestrator/engine.py +821 -0
  107. harness/orchestrator/handoff.py +33 -0
  108. harness/orchestrator/lifecycle.py +69 -0
  109. harness/orchestrator/native_resume.py +93 -0
  110. harness/orchestrator/policies.py +101 -0
  111. harness/orchestrator/reconcile.py +39 -0
  112. harness/orchestrator/run_graph.py +62 -0
  113. harness/orchestrator/snapshot.py +49 -0
  114. harness/schemas/__init__.py +1 -0
  115. harness/schemas/adapter.py +26 -0
  116. harness/schemas/approval.py +25 -0
  117. harness/schemas/artifact.py +17 -0
  118. harness/schemas/common.py +20 -0
  119. harness/schemas/memory.py +50 -0
  120. harness/schemas/policy.py +39 -0
  121. harness/schemas/run.py +116 -0
  122. harness/schemas/session.py +93 -0
  123. harness/schemas/task.py +24 -0
  124. harness/security/__init__.py +5 -0
  125. harness/security/approval_policy.py +107 -0
  126. harness/security/auth.py +106 -0
  127. harness/security/permissions.py +59 -0
  128. harness/security/risk.py +59 -0
  129. harness/security/secrets.py +49 -0
  130. harness/services/__init__.py +1 -0
  131. harness/services/adapters_health.py +212 -0
  132. harness/services/approvals.py +84 -0
  133. harness/services/artifacts.py +78 -0
  134. harness/services/attachments.py +228 -0
  135. harness/services/comparison.py +345 -0
  136. harness/services/doctor.py +156 -0
  137. harness/services/files.py +287 -0
  138. harness/services/mcp_servers.py +179 -0
  139. harness/services/project_git.py +101 -0
  140. harness/services/retention.py +97 -0
  141. harness/services/runs.py +155 -0
  142. harness/services/runs_diff.py +201 -0
  143. harness/services/sessions.py +242 -0
  144. harness/services/ssh.py +300 -0
  145. harness/services/stats.py +132 -0
  146. harness/services/tasks.py +41 -0
  147. harness/services/workspaces.py +184 -0
  148. harness/services/worktrees.py +439 -0
  149. harness/settings.py +186 -0
  150. harness/skills/__init__.py +11 -0
  151. harness/skills/manager.py +141 -0
  152. harness/tools/__init__.py +1 -0
  153. harness/tools/fs_tools.py +295 -0
  154. harness/workers/__init__.py +0 -0
  155. harness/workers/dispatch.py +26 -0
  156. harness/workers/inprocess.py +135 -0
@@ -0,0 +1,821 @@
1
+ """Orchestration engine.
2
+
3
+ Responsibilities:
4
+ - Materialize a session's runs from a compiled policy (one Run row per graph node).
5
+ - Execute the run graph wave-by-wave: independent nodes run concurrently; a node
6
+ receives the outputs of its dependencies as additional context.
7
+ - For each node: assemble context (objective + retrieved memory + upstream
8
+ outputs), drive the adapter, persist every NormalizedEvent to the append-only
9
+ ``run_events`` ledger, publish to the live bus, and project terminal state onto
10
+ the Run row.
11
+ - Tolerate partial failure: one node failing does not abort independent nodes.
12
+
13
+ The same ``execute_session`` coroutine is used by the in-process asyncio dispatch
14
+ worker (``harness/workers/inprocess.py``) and directly by tests/CLI (inline), keeping
15
+ behaviour identical across paths.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import contextlib
22
+ import uuid
23
+ from dataclasses import dataclass
24
+ from datetime import UTC, datetime
25
+
26
+ from harness_sdk.base import BaseAdapter, SessionContext
27
+ from harness_sdk.events import NormalizedEvent
28
+ from sqlalchemy import select
29
+ from sqlalchemy.ext.asyncio import AsyncSession
30
+
31
+ from harness.adapters.registry import build_adapter
32
+ from harness.eventbus.inprocess_bus import InProcessEventBus
33
+ from harness.memory.context_budget import (
34
+ build_memory_context,
35
+ context_window,
36
+ estimate_tokens,
37
+ )
38
+ from harness.memory.service import MemoryService
39
+ from harness.models.artifact import Artifact
40
+ from harness.models.enums import (
41
+ AdapterKind,
42
+ ArtifactKind,
43
+ RunEventType,
44
+ RunStatus,
45
+ SessionStatus,
46
+ )
47
+ from harness.models.provider_config import ProviderConfig
48
+ from harness.models.run import Run
49
+ from harness.models.run_event import RunEvent
50
+ from harness.models.session import Session
51
+ from harness.models.workspace import Workspace
52
+ from harness.observability.logging import get_logger
53
+ from harness.observability.metrics import (
54
+ ACTIVE_RUNS,
55
+ ADAPTER_LATENCY,
56
+ RUN_FAILURES,
57
+ RUN_TOTAL,
58
+ TOKENS_TOTAL,
59
+ )
60
+ from harness.orchestrator.lifecycle import InMemoryCancellationRegistry
61
+ from harness.orchestrator.policies import CompiledPolicy, RunNodeSpec
62
+ from harness.orchestrator.run_graph import RunGraph
63
+ from harness.services.workspaces import (
64
+ effective_host,
65
+ effective_project,
66
+ effective_write_mode,
67
+ )
68
+ from harness.settings import get_settings
69
+
70
+ log = get_logger("orchestrator")
71
+
72
+
73
+ @dataclass
74
+ class NodeResult:
75
+ node_id: str
76
+ run_id: uuid.UUID
77
+ provider: str
78
+ status: RunStatus
79
+ output: str
80
+ error: str | None = None
81
+
82
+
83
+ async def create_runs(
84
+ db: AsyncSession,
85
+ *,
86
+ session_id: uuid.UUID,
87
+ policy: CompiledPolicy,
88
+ provider_configs: dict[str, ProviderConfig],
89
+ ) -> dict[str, Run]:
90
+ """Create one queued Run per graph node for THIS turn.
91
+
92
+ A session is a conversation: the same provider may be invoked across multiple
93
+ turns (e.g. claude → agy → claude). Node ids (``claude-0``) are stable per
94
+ policy, so the idempotency key carries a per-turn token to keep each turn's runs
95
+ distinct while preserving the human-readable ``graph_node_id`` used for
96
+ within-turn dependency wiring."""
97
+ turn = uuid.uuid4().hex[:12]
98
+ runs: dict[str, Run] = {}
99
+ for node in policy.nodes:
100
+ pc = provider_configs.get(node.provider)
101
+ kind = pc.kind if pc else AdapterKind.mock
102
+ run = Run(
103
+ session_id=session_id,
104
+ provider_config_id=pc.id if pc else None,
105
+ adapter_kind=kind,
106
+ role=node.role,
107
+ status=RunStatus.queued,
108
+ graph_node_id=node.node_id,
109
+ idempotency_key=f"{session_id}:{node.node_id}:{turn}",
110
+ )
111
+ db.add(run)
112
+ runs[node.node_id] = run
113
+ await db.flush()
114
+ return runs
115
+
116
+
117
+ @dataclass
118
+ class _Collected:
119
+ """In-memory result of driving an adapter — produced WITHOUT touching the DB so
120
+ multiple nodes in a wave can run concurrently. Live events still stream to the
121
+ in-process event bus (shared within the process, safe under concurrency)."""
122
+
123
+ events: list[NormalizedEvent]
124
+ final_output: str
125
+ status: RunStatus
126
+ error: str | None
127
+ cost: dict
128
+ started_at: datetime
129
+ finished_at: datetime
130
+
131
+
132
+ async def _collect_adapter(
133
+ *,
134
+ adapter: BaseAdapter,
135
+ run_id: str,
136
+ ctx: SessionContext,
137
+ message: str,
138
+ bus: InProcessEventBus | None,
139
+ canceller: InMemoryCancellationRegistry | None,
140
+ publish_user: bool = True,
141
+ publish_done: bool = True,
142
+ attachments_meta: list[dict] | None = None,
143
+ ) -> _Collected:
144
+ """Collect events from *adapter* for one attempt.
145
+
146
+ Args:
147
+ publish_user: When False, skip publishing the initial user-message event
148
+ to the bus. Pass False on retry attempts where subscribers have
149
+ already received the user message from the first attempt.
150
+ publish_done: When False, skip calling ``bus.publish_done`` at the end.
151
+ Pass False when the caller will conditionally publish done itself
152
+ (e.g. only after a successful retry completes).
153
+ """
154
+ output_parts: list[str] = []
155
+ final_message = ""
156
+ status = RunStatus.succeeded
157
+ error: str | None = None
158
+ cost: dict = {}
159
+ events: list[NormalizedEvent] = []
160
+ started = datetime.now(UTC)
161
+ ACTIVE_RUNS.inc()
162
+ try:
163
+ from harness_sdk.events import MessageEvent
164
+ user_msg = MessageEvent(seq=0, role="user", text=message,
165
+ attachments=attachments_meta or [])
166
+ events.append(user_msg)
167
+ if bus is not None and publish_user:
168
+ await bus.publish(
169
+ run_id,
170
+ {"type": user_msg.type, "seq": user_msg.seq,
171
+ **user_msg.model_dump(exclude={"type", "seq"})},
172
+ )
173
+
174
+ provider_session = await adapter.start_session(ctx)
175
+ seq_counter = 1
176
+ # Per-run deadline: a non-responding adapter (stuck remote API, wedged CLI) is
177
+ # cancelled and the run fails cleanly instead of hanging the whole stack.
178
+ deadline = get_settings().run_timeout_s
179
+ stall = get_settings().stall_timeout_s
180
+ async with asyncio.timeout(deadline):
181
+ it = adapter.stream_events(provider_session, message).__aiter__()
182
+ _it_exhausted = False # set True only on the normal StopAsyncIteration path
183
+ try:
184
+ while True:
185
+ try:
186
+ if stall > 0:
187
+ event = await asyncio.wait_for(it.__anext__(), timeout=stall)
188
+ else:
189
+ event = await it.__anext__()
190
+ except StopAsyncIteration:
191
+ _it_exhausted = True
192
+ break
193
+ except TimeoutError:
194
+ # Per-event stall detected: the adapter stopped yielding events.
195
+ # This is caught here (inside asyncio.wait_for) so it cannot be
196
+ # confused with the outer asyncio.timeout(deadline) TimeoutError.
197
+ status = RunStatus.failed
198
+ error = f"no output for {stall}s (stalled)"
199
+ with contextlib.suppress(Exception):
200
+ await adapter.cancel_run(run_id)
201
+ break
202
+ event.seq = seq_counter
203
+ seq_counter += 1
204
+ events.append(event)
205
+ if bus is not None:
206
+ await bus.publish(
207
+ run_id,
208
+ {"type": getattr(event.type, "value", event.type), "seq": event.seq,
209
+ **event.model_dump(exclude={"type", "seq"}, mode="json")},
210
+ )
211
+ if event.type == "token":
212
+ output_parts.append(event.text)
213
+ elif event.type == "message":
214
+ final_message = event.text
215
+ elif event.type == "cost":
216
+ k = adapter.kind.value
217
+ TOKENS_TOTAL.labels(k, "prompt").inc(event.prompt_tokens)
218
+ TOKENS_TOTAL.labels(k, "completion").inc(event.completion_tokens)
219
+ cost = event.model_dump(exclude={"type", "seq"})
220
+ elif event.type == "error":
221
+ status = RunStatus.failed
222
+ error = event.message
223
+ elif event.type == "status" and event.status == "needs_approval":
224
+ # A tool call hit the approval gate (see fs_tools.execute /
225
+ # ApprovalRequired). The adapter already stopped emitting
226
+ # events; the accompanying adapter_meta event carries the
227
+ # pending request, materialized into an Approval row by
228
+ # _create_pending_approval once this node is persisted.
229
+ status = RunStatus.needs_approval
230
+ if canceller is not None and await canceller.is_cancelled(run_id):
231
+ status = RunStatus.cancelled
232
+ await adapter.cancel_run(run_id)
233
+ break
234
+ finally:
235
+ # Close the iterator whenever the loop exits abnormally (stall break,
236
+ # cancel break). aclose() on an already-exhausted generator is a no-op,
237
+ # so this is safe on the happy path too. Closing deterministically
238
+ # triggers the inner generator's finally/_reap instead of leaving
239
+ # it to GC — which in turn lets adapter backstops run synchronously.
240
+ if not _it_exhausted:
241
+ with contextlib.suppress(Exception):
242
+ await it.aclose()
243
+ except TimeoutError: # adapter didn't respond within the per-run deadline
244
+ from harness_sdk.events import ErrorEvent
245
+ status = RunStatus.failed
246
+ error = f"provider '{adapter.config.name}' did not respond within {deadline}s"
247
+ RUN_FAILURES.labels(adapter.kind.value, "timeout").inc()
248
+ log.error("node_timeout", run_id=run_id, error=error)
249
+ with contextlib.suppress(Exception): # best-effort cleanup of the stuck adapter
250
+ await adapter.cancel_run(run_id)
251
+ err = ErrorEvent(seq=len(events), message=error, retriable=True)
252
+ events.append(err)
253
+ if bus is not None:
254
+ await bus.publish(
255
+ run_id, {"type": err.type, "seq": err.seq,
256
+ **err.model_dump(exclude={"type", "seq"})},
257
+ )
258
+ except Exception as exc: # adapter blew up — contain it to this node
259
+ status = RunStatus.failed
260
+ error = f"{type(exc).__name__}: {exc}"
261
+ RUN_FAILURES.labels(adapter.kind.value, type(exc).__name__).inc()
262
+ log.error("node_failed", run_id=run_id, error=error)
263
+ finally:
264
+ ACTIVE_RUNS.dec()
265
+ if bus is not None and publish_done:
266
+ await bus.publish_done(run_id)
267
+ return _Collected(
268
+ events=events,
269
+ final_output=final_message or "".join(output_parts).strip(),
270
+ status=status,
271
+ error=error,
272
+ cost=cost,
273
+ started_at=started,
274
+ finished_at=datetime.now(UTC),
275
+ )
276
+
277
+
278
+ def _persist_artifacts(
279
+ db: AsyncSession,
280
+ *,
281
+ run: Run,
282
+ workspace_id: uuid.UUID,
283
+ session_id: uuid.UUID,
284
+ c: _Collected,
285
+ ) -> None:
286
+ """Materialize Artifact rows. Provides shared transcript persistence: every run
287
+ gets a canonical transcript artifact (provider-independent), plus a row for any
288
+ ArtifactEvent the adapter emitted (files/patches/plans/docs)."""
289
+ import hashlib
290
+
291
+ for ev in c.events:
292
+ if ev.type == "artifact":
293
+ db.add(
294
+ Artifact(
295
+ workspace_id=workspace_id, session_id=session_id, run_id=run.id,
296
+ kind=ArtifactKind(ev.kind), uri=ev.uri,
297
+ sha256=ev.sha256 or hashlib.sha256(ev.uri.encode()).hexdigest(),
298
+ meta=ev.meta or {},
299
+ )
300
+ )
301
+ transcript = c.final_output or ""
302
+ if transcript:
303
+ db.add(
304
+ Artifact(
305
+ workspace_id=workspace_id, session_id=session_id, run_id=run.id,
306
+ kind=ArtifactKind.doc,
307
+ uri=f"harness://transcript/{run.id}",
308
+ sha256=hashlib.sha256(transcript.encode()).hexdigest(),
309
+ size=len(transcript.encode()),
310
+ meta={"kind": "transcript", "provider": run.adapter_kind.value,
311
+ "content": transcript[:20_000]},
312
+ )
313
+ )
314
+
315
+
316
+ def _persist_collected(
317
+ db: AsyncSession, *, run: Run, adapter_kind: AdapterKind, provider: str, c: _Collected
318
+ ) -> NodeResult:
319
+ """Write a collected node's events + terminal state onto the shared session.
320
+ Called sequentially (one node at a time) so the single AsyncSession is never
321
+ flushed concurrently."""
322
+ run.started_at = c.started_at
323
+ for ev in c.events:
324
+ db.add(
325
+ RunEvent(
326
+ run_id=run.id, seq=ev.seq, type=RunEventType(ev.type),
327
+ payload=ev.model_dump(exclude={"type", "seq"}),
328
+ )
329
+ )
330
+ run.final_output = c.final_output
331
+ run.status = c.status
332
+ run.error = c.error
333
+ run.cost = c.cost
334
+ run.finished_at = c.finished_at
335
+ ADAPTER_LATENCY.labels(adapter_kind.value, provider).observe(
336
+ (c.finished_at - c.started_at).total_seconds()
337
+ )
338
+ RUN_TOTAL.labels(adapter_kind.value, c.status.value).inc()
339
+ return NodeResult(
340
+ node_id=run.graph_node_id, run_id=run.id, provider=provider,
341
+ status=c.status, output=c.final_output, error=c.error,
342
+ )
343
+
344
+
345
+ async def _create_pending_approval(
346
+ db: AsyncSession, *, run: Run, session_id: uuid.UUID, collected: _Collected
347
+ ) -> None:
348
+ """When a node paused for a human decision, materialize the Approval row
349
+ the webgui/CLI poll for, from the adapter_meta payload the adapter emitted
350
+ alongside its ``needs_approval`` status event."""
351
+ from harness.models.enums import ApprovalActionClass
352
+ from harness.services.approvals import create_approval
353
+
354
+ meta = next(
355
+ (
356
+ ev.data.get("approval_request") for ev in collected.events
357
+ if ev.type == "adapter_meta" and "approval_request" in ev.data
358
+ ),
359
+ None,
360
+ )
361
+ if meta is None:
362
+ return
363
+ action_class_raw = meta.get("action_class")
364
+ action_class = (
365
+ ApprovalActionClass(action_class_raw) if action_class_raw else ApprovalActionClass.command
366
+ )
367
+ await create_approval(
368
+ db, session_id=session_id, run_id=run.id, action_class=action_class, payload=meta,
369
+ )
370
+
371
+
372
+ async def execute_node(
373
+ db: AsyncSession,
374
+ *,
375
+ adapter: BaseAdapter,
376
+ run: Run,
377
+ ctx: SessionContext,
378
+ message: str,
379
+ bus: InProcessEventBus | None = None,
380
+ canceller: InMemoryCancellationRegistry | None = None,
381
+ ) -> NodeResult:
382
+ """Drive a single adapter run to completion and persist it. Convenience wrapper
383
+ over collect+persist for the single-node path."""
384
+ run.status = RunStatus.running
385
+ await db.flush()
386
+ collected = await _collect_adapter(
387
+ adapter=adapter, run_id=str(run.id), ctx=ctx, message=message,
388
+ bus=bus, canceller=canceller,
389
+ )
390
+ result = _persist_collected(
391
+ db, run=run, adapter_kind=adapter.kind, provider=adapter.config.name, c=collected
392
+ )
393
+ if collected.status == RunStatus.needs_approval:
394
+ await _create_pending_approval(db, run=run, session_id=run.session_id, collected=collected)
395
+ await db.flush()
396
+ return result
397
+
398
+
399
+ async def execute_session(
400
+ db: AsyncSession,
401
+ *,
402
+ session_id: uuid.UUID,
403
+ policy: CompiledPolicy,
404
+ bus: InProcessEventBus | None = None,
405
+ canceller: InMemoryCancellationRegistry | None = None,
406
+ message_override: str | None = None,
407
+ model_override: str | None = None,
408
+ model_overrides: dict[str, str] | None = None,
409
+ attachments: list[dict] | None = None,
410
+ ) -> list[NodeResult]:
411
+ """Execute the full run graph for a session, wave by wave.
412
+
413
+ Within a wave the slow adapter/LLM calls run concurrently (true parallel
414
+ provider execution), but their DB writes are applied sequentially on the
415
+ shared session — SQLAlchemy's AsyncSession is not safe for concurrent flush.
416
+
417
+ ``message_override`` supplies a per-turn prompt (e.g. a follow-up on resume);
418
+ when set it is the message sent to each node instead of the session's original
419
+ objective, while shared memory is still retrieved and injected as usual.
420
+
421
+ ``model_override`` applies one model to every node; ``model_overrides`` maps a
422
+ provider name to its own model and wins for the providers it names. Together
423
+ they let a fanout wave run each agent on a different model."""
424
+ import asyncio
425
+
426
+ sess = (await db.execute(select(Session).where(Session.id == session_id))).scalar_one()
427
+ workspace = (
428
+ await db.execute(select(Workspace).where(Workspace.id == sess.workspace_id))
429
+ ).scalar_one()
430
+
431
+ # Per-workspace runtime settings: which project folder the agents operate in and
432
+ # whether they may edit files. Set via `ags project set/mode`; fall back to the
433
+ # process cwd / HARNESS_WRITE default. Injected into every node's context below.
434
+ # A project_host means the folder lives on a remote ssh host — the adapter wraps
435
+ # its CLI invocation in ssh, and local-filesystem features are skipped.
436
+ project_dir, _ = effective_project(workspace.settings or {})
437
+ # Per-session override: a session may pin its own plan/write mode (set via
438
+ # `/mode` in chat or the GUI toggle); absent/None inherits the workspace value.
439
+ write_mode = effective_write_mode(sess.settings or {}, workspace.settings or {})
440
+ project_host = effective_host(workspace.settings or {})
441
+
442
+ # Private workspace: an isolated session runs in its own git worktree, not
443
+ # the shared project dir. The service owns all downgrade cases (remote host,
444
+ # non-git dir, git failure) and records the reason on the session.
445
+ if (sess.settings or {}).get("workspace_mode") == "worktree":
446
+ from harness.services.worktrees import ensure_worktree
447
+
448
+ provisioned = await ensure_worktree(
449
+ db, session=sess, workspace=workspace, project_dir=project_dir,
450
+ project_host=project_host,
451
+ )
452
+ if provisioned is not None:
453
+ project_dir = provisioned[0]
454
+
455
+ provider_configs = {
456
+ pc.name: pc
457
+ for pc in (
458
+ await db.execute(
459
+ select(ProviderConfig).where(ProviderConfig.enabled.is_(True))
460
+ )
461
+ ).scalars()
462
+ }
463
+ # Execute only THIS turn's runs: the ones still queued. Earlier turns are
464
+ # terminal and must not be re-run (the same provider can recur across turns).
465
+ all_runs = list((
466
+ await db.execute(
467
+ select(Run).where(Run.session_id == session_id).order_by(Run.created_at.asc())
468
+ )
469
+ ).scalars())
470
+ queued = [r for r in all_runs if r.status == RunStatus.queued]
471
+
472
+ # Provider of the most recent terminal run — drives native-resume vs handoff.
473
+ terminal = [r for r in all_runs if r.status in (RunStatus.succeeded, RunStatus.failed)]
474
+ last_provider: str | None = None
475
+ if terminal:
476
+ last_run = max(terminal, key=lambda r: r.created_at)
477
+ pc_by_id = {pc.id: pc.name for pc in provider_configs.values()}
478
+ last_provider = pc_by_id.get(last_run.provider_config_id) or last_run.adapter_kind.value
479
+
480
+ if queued:
481
+ runs = {r.graph_node_id: r for r in queued}
482
+ elif not all_runs:
483
+ runs = await create_runs(
484
+ db, session_id=session_id, policy=policy, provider_configs=provider_configs
485
+ )
486
+ else:
487
+ # No queued work for this turn — nothing to execute.
488
+ return []
489
+
490
+ sess.status = SessionStatus.running
491
+ await db.flush()
492
+
493
+ # The message for this turn: a per-turn override (resume follow-up) or the
494
+ # session's original objective.
495
+ turn_message = message_override or sess.canonical_objective
496
+
497
+ # Node-independent attachment metadata for the user MessageEvent (chips /
498
+ # thumbnails in the transcript) — computed once, reused for every node.
499
+ attachments_meta = [
500
+ {k: a[k] for k in ("id", "name", "media_type", "size", "image")}
501
+ for a in (attachments or [])
502
+ ] or None
503
+
504
+ # Retrieve shared memory once per session execution; injected into every node.
505
+ semantic_block = ""
506
+ try:
507
+ result = await MemoryService(db).retrieve(
508
+ workspace_slug=workspace.slug,
509
+ query=turn_message,
510
+ session_id=str(session_id),
511
+ )
512
+ semantic_block = result.as_context_block()
513
+ except Exception as exc: # memory must never sink a run
514
+ log.warning("memory_retrieval_failed", error=str(exc))
515
+
516
+ # Build the conversational transcript blocks (the variable part of the context).
517
+ # They're assembled here once; each node then budgets them to ITS model's window
518
+ # (see build_memory_context in _collect_one) — so a small-window model trims while
519
+ # a 1M-window model keeps the full history.
520
+ from harness.models.run_event import RunEvent
521
+ session_summary = sess.summary or ""
522
+ transcript_blocks: list[str] = []
523
+ completed_runs = [r for r in all_runs if r.status in (RunStatus.succeeded, RunStatus.failed)]
524
+ if completed_runs:
525
+ run_ids = [r.id for r in completed_runs]
526
+ stmt = (
527
+ select(RunEvent)
528
+ .where(RunEvent.run_id.in_(run_ids))
529
+ .order_by(RunEvent.run_id, RunEvent.seq)
530
+ )
531
+ past_events = (await db.execute(stmt)).scalars().all()
532
+ provider_of = {r.id: r.adapter_kind.value for r in completed_runs}
533
+ for ev in past_events:
534
+ provider = provider_of.get(ev.run_id, "Assistant")
535
+ if ev.type.value == "message":
536
+ role = ev.payload.get("role", "assistant")
537
+ text = ev.payload.get("text", "")
538
+ if text:
539
+ if role == "user":
540
+ transcript_blocks.append(f"User: {text}")
541
+ else:
542
+ transcript_blocks.append(f"[{provider}]: {text}")
543
+ elif ev.type.value == "tool_call":
544
+ name = ev.payload.get("name", "")
545
+ args = ev.payload.get("arguments", {})
546
+ transcript_blocks.append(f"[{provider}] ran tool '{name}' with {args}")
547
+ elif ev.type.value == "tool_result":
548
+ out = ev.payload.get("output", "")
549
+ if len(out) > 500:
550
+ out = out[:500] + "... (truncated)"
551
+ transcript_blocks.append(f"[Tool Result]: {out}")
552
+
553
+ # Load enabled MCP servers once; each node filters them to its workspace/provider
554
+ # and mode in-memory (no DB access on the concurrent fan-out path). Servers fail
555
+ # soft — a load error simply means no MCP tools this run.
556
+ from harness.mcp.registry import filter_servers, load_enabled_servers
557
+ mcp_all_servers: list[dict] = []
558
+ try:
559
+ mcp_all_servers = await load_enabled_servers(db)
560
+ except Exception as exc: # MCP must never sink a run
561
+ log.warning("mcp_load_failed", error=str(exc))
562
+
563
+ graph = RunGraph.from_specs(policy.nodes)
564
+ node_by_id: dict[str, RunNodeSpec] = {n.node_id: n for n in policy.nodes}
565
+ results: dict[str, NodeResult] = {}
566
+
567
+ for wave in graph.waves():
568
+ # Phase 1: drive every ready node's adapter concurrently (no DB writes).
569
+ async def _collect_one(
570
+ node: RunNodeSpec,
571
+ ) -> tuple[RunNodeSpec, BaseAdapter | None, _Collected | None, dict, str | None]:
572
+ pc = provider_configs.get(node.provider)
573
+ if pc is None:
574
+ return node, None, None, {}, None
575
+ # Per-provider pick wins over the wave-wide one; without either the
576
+ # provider keeps its configured default.
577
+ node_model = (model_overrides or {}).get(node.provider) or model_override
578
+ if node_model:
579
+ from harness_sdk.base import AdapterConfig
580
+
581
+ from harness.adapters.registry import build_adapter_from_config
582
+ from harness.security.secrets import resolve_secret_ref
583
+ cfg = AdapterConfig(
584
+ name=pc.name,
585
+ kind=pc.kind,
586
+ base_url=pc.base_url,
587
+ model=node_model,
588
+ api_key=resolve_secret_ref(pc.secret_ref),
589
+ params=pc.params or {},
590
+ capabilities=pc.capabilities or {},
591
+ )
592
+ adapter = build_adapter_from_config(cfg)
593
+ else:
594
+ adapter = build_adapter(pc)
595
+ upstream = "\n\n".join(
596
+ f"[{node_by_id[d].role.value}:{results[d].provider}]\n{results[d].output}"
597
+ for d in node.depends_on if d in results
598
+ )
599
+ run = runs[node.node_id]
600
+ message = turn_message
601
+ if upstream:
602
+ message += f"\n\n## Upstream provider outputs\n{upstream}"
603
+ # Attachments: images ride as native content blocks only where the
604
+ # adapter supports them (claude or a vision-capable provider, local
605
+ # project); everything else — documents, and images demoted for
606
+ # other providers — is referenced by absolute path for the agent to
607
+ # read with its own tools.
608
+ def _native_image(a: dict) -> bool:
609
+ vision = pc.kind == AdapterKind.claude_code or bool(
610
+ (pc.capabilities or {}).get("vision")
611
+ )
612
+ return bool(a.get("image")) and vision and not project_host
613
+
614
+ image_atts = [a for a in (attachments or []) if _native_image(a)]
615
+ file_atts = [a for a in (attachments or []) if not _native_image(a)]
616
+ if file_atts:
617
+ listing = "\n".join(
618
+ f"- {a['path']} ({a['name']}, {a['size']} bytes)" for a in file_atts
619
+ )
620
+ message += f"\n\n## Attached files (read with your own tools)\n{listing}"
621
+ # Budget the injected context to THIS node's model window: recent turns
622
+ # verbatim, older ones folded into the session summary. Big-window models
623
+ # (1M) keep the full history untouched.
624
+ params = pc.params or {}
625
+ from harness.orchestrator.native_resume import decide_resume
626
+ # For antigravity: probe the CLI binary once and thread the supported
627
+ # flags into decide_resume so native-resume auto-enables when --conversation
628
+ # is advertised. We use getattr so non-CLI adapters (BaseAdapter subclasses
629
+ # without ensure_probe) are skipped without touching the SDK.
630
+ # Probe errors must never sink the run — failures produce probed=None.
631
+ probed: set[str] | None = None
632
+ if pc.kind == AdapterKind.antigravity:
633
+ ensure = getattr(adapter, "ensure_probe", None)
634
+ if ensure is not None:
635
+ try:
636
+ probe_result = await ensure()
637
+ probed = probe_result.get("flags")
638
+ except Exception:
639
+ probed = None
640
+ resume = decide_resume(
641
+ provider_name=pc.name, adapter_kind=pc.kind,
642
+ native_sessions=sess.native_sessions or {},
643
+ last_provider=last_provider, params=params,
644
+ probed_flags=probed,
645
+ )
646
+ window = context_window(pc.kind, node_model or pc.model, params)
647
+ node_transcript = [] if resume.native_id else transcript_blocks
648
+ # On a switch the handoff block carries the summary — don't inject it twice.
649
+ node_summary = "" if (resume.native_id or resume.is_switch) else session_summary
650
+ node_memory = build_memory_context(
651
+ semantic_block, node_transcript, node_summary,
652
+ window=window, message_tokens=estimate_tokens(message),
653
+ reserve_tokens=params.get("max_tokens"),
654
+ )
655
+ if resume.is_switch and last_provider:
656
+ from harness.orchestrator.handoff import build_handoff_block, files_touched_from_blocks
657
+ node_memory = build_handoff_block(
658
+ from_provider=last_provider, summary=session_summary,
659
+ recent_blocks=transcript_blocks,
660
+ files_touched=files_touched_from_blocks(transcript_blocks),
661
+ ) + "\n\n" + node_memory
662
+ # MCP servers offered to THIS node (workspace+provider bind, mode-gated).
663
+ node_mcp_servers = filter_servers(
664
+ mcp_all_servers,
665
+ workspace_slug=workspace.slug,
666
+ provider=pc.name,
667
+ read_only=not write_mode,
668
+ )
669
+ # Managed-skills awareness for the CLI agents (claude/agy), injected into the
670
+ # system prompt. The CLIs natively load the skills; this tells them what's
671
+ # available. Skipped for non-CLI providers (no native Skill tool).
672
+ system_prompt = None
673
+ if pc.kind in (AdapterKind.claude_code, AdapterKind.antigravity):
674
+ from harness.skills import index_for
675
+ system_prompt = index_for(provider=pc.name, workspace=workspace.slug) or None
676
+ extra: dict = {}
677
+ if project_host:
678
+ extra["ssh_host"] = project_host
679
+ if node_mcp_servers:
680
+ extra["mcp_servers"] = node_mcp_servers
681
+ if resume.native_id:
682
+ if pc.kind == AdapterKind.antigravity:
683
+ extra["conversation_id"] = resume.native_id
684
+ else:
685
+ extra["native_session_id"] = resume.native_id
686
+ if image_atts:
687
+ extra["attachments_images"] = [
688
+ {"path": a["path"], "media_type": a["media_type"], "name": a["name"]}
689
+ for a in image_atts
690
+ ]
691
+ ctx = SessionContext(
692
+ run_id=str(run.id),
693
+ session_id=str(session_id),
694
+ workspace_slug=workspace.slug,
695
+ objective=turn_message,
696
+ constraints=sess.constraints or {},
697
+ system_prompt=system_prompt,
698
+ memory_context=node_memory,
699
+ workspace_dir=project_dir,
700
+ read_only=not write_mode,
701
+ trust_policy=workspace.trust_policy.value,
702
+ extra=extra,
703
+ )
704
+ # Build context metadata (safe: pure function, no DB write).
705
+ # Returned in the tuple and assigned to run.context_meta in the
706
+ # sequential Phase-2 loop below to avoid concurrent ORM writes.
707
+ from harness.orchestrator.native_resume import context_meta_for
708
+ ctx_meta = context_meta_for(
709
+ resume=resume,
710
+ node_memory=node_memory,
711
+ transcript_blocks=node_transcript,
712
+ window=window,
713
+ )
714
+ # Record the model this node actually ran on — with per-provider
715
+ # overrides the answer differs per run, and the UI/API have no other
716
+ # way to tell which one was used.
717
+ ctx_meta = {**ctx_meta, "model": node_model or pc.model}
718
+ # Caller is the sole publisher of done — always suppress inside _collect_adapter
719
+ # so that the else-branch below is the single publish_done site for every path.
720
+ snapshot_sha: str | None = None
721
+ # Remote projects are never snapshotted here — project_dir names a dir on
722
+ # the remote host; a same-named local dir would be the wrong repo.
723
+ if not ctx.read_only and project_dir and not project_host:
724
+ from harness.orchestrator.snapshot import git_snapshot
725
+ snapshot_sha = await git_snapshot(project_dir)
726
+ collected = await _collect_adapter(
727
+ adapter=adapter, run_id=str(run.id), ctx=ctx, message=message,
728
+ bus=bus, canceller=canceller,
729
+ publish_done=False,
730
+ attachments_meta=attachments_meta,
731
+ )
732
+ if resume.native_id and collected.status == RunStatus.failed:
733
+ log.warning("native_resume_failed_retrying", provider=pc.name,
734
+ native_id=resume.native_id, error=collected.error)
735
+ extra.pop("native_session_id", None)
736
+ extra.pop("conversation_id", None)
737
+ retry_memory = build_memory_context(
738
+ semantic_block, transcript_blocks, session_summary,
739
+ window=window, message_tokens=estimate_tokens(message),
740
+ reserve_tokens=params.get("max_tokens"),
741
+ )
742
+ ctx = ctx.model_copy(update={
743
+ "memory_context": retry_memory,
744
+ "extra": extra,
745
+ })
746
+ # Recompute ctx_meta for the retry: native-resume is abandoned so mode
747
+ # must be "replay" with the full transcript and correct memory_chars.
748
+ from harness.orchestrator.native_resume import ResumeDecision
749
+ ctx_meta = context_meta_for(
750
+ resume=ResumeDecision(native_id=None, is_switch=False),
751
+ node_memory=retry_memory,
752
+ transcript_blocks=transcript_blocks,
753
+ window=window,
754
+ )
755
+ ctx_meta = {**ctx_meta, "model": node_model or pc.model}
756
+ # Retry: subscriber already saw the user message; publish_done=True (default)
757
+ # so the stream terminates after this final attempt.
758
+ collected = await _collect_adapter(
759
+ adapter=adapter, run_id=str(run.id), ctx=ctx, message=message,
760
+ bus=bus, canceller=canceller,
761
+ publish_user=False,
762
+ attachments_meta=attachments_meta,
763
+ )
764
+ else:
765
+ # First attempt succeeded (or no retry possible): emit done now.
766
+ if bus is not None:
767
+ await bus.publish_done(str(run.id))
768
+ return node, adapter, collected, ctx_meta, snapshot_sha
769
+
770
+ collected_wave = await asyncio.gather(*[_collect_one(n) for n in wave])
771
+
772
+ # Phase 2: persist sequentially on the shared session, then flush once.
773
+ for node, adapter, collected, ctx_meta, snapshot_sha in collected_wave:
774
+ run = runs[node.node_id]
775
+ if adapter is None or collected is None:
776
+ run.status = RunStatus.failed
777
+ run.error = f"provider '{node.provider}' not enabled/configured"
778
+ run.finished_at = datetime.now(UTC)
779
+ results[node.node_id] = NodeResult(
780
+ node.node_id, run.id, node.provider, RunStatus.failed, "", run.error
781
+ )
782
+ continue
783
+ # Assign context_meta here (sequential) — safe for the shared session.
784
+ run.context_meta = ctx_meta
785
+ results[node.node_id] = _persist_collected(
786
+ db, run=run, adapter_kind=adapter.kind, provider=adapter.config.name, c=collected
787
+ )
788
+ if collected.status == RunStatus.needs_approval:
789
+ await _create_pending_approval(
790
+ db, run=run, session_id=session_id, collected=collected
791
+ )
792
+ _persist_artifacts(
793
+ db, run=run, workspace_id=workspace.id, session_id=session_id, c=collected
794
+ )
795
+ if snapshot_sha:
796
+ db.add(
797
+ Artifact(
798
+ workspace_id=workspace.id, session_id=session_id, run_id=run.id,
799
+ kind=ArtifactKind.patch,
800
+ uri=f"git-snapshot://{snapshot_sha}",
801
+ sha256=snapshot_sha,
802
+ size=0,
803
+ meta={"git_snapshot": snapshot_sha, "pre_run": True},
804
+ )
805
+ )
806
+ from harness.orchestrator.native_resume import extract_native_id
807
+ nsid = extract_native_id(collected.events or [], adapter.kind)
808
+ if nsid:
809
+ sess.native_sessions = {**(sess.native_sessions or {}), adapter.config.name: nsid}
810
+ await db.flush()
811
+
812
+ any_ok = any(r.status == RunStatus.succeeded for r in results.values())
813
+ any_parked = any(r.status == RunStatus.needs_approval for r in results.values())
814
+ if any_parked:
815
+ # Waiting on a human decision — not failed, not done. Stays "running" so
816
+ # reconcile.py doesn't sweep it up as orphaned/interrupted on restart.
817
+ sess.status = SessionStatus.running
818
+ else:
819
+ sess.status = SessionStatus.completed if any_ok else SessionStatus.failed
820
+ await db.flush()
821
+ return list(results.values())