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.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- 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())
|