pcode 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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Worktree-aware delegation over the pinned Harness run/event/budget machinery.
|
|
2
|
+
|
|
3
|
+
Only the built-in worker is reconstructible in another workspace. Specialized
|
|
4
|
+
extension agents keep their own capabilities and shared-workspace semantics.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
from contextvars import ContextVar
|
|
14
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Literal
|
|
17
|
+
|
|
18
|
+
from pydantic_ai import ModelRetry, RunContext
|
|
19
|
+
from pydantic_ai.workspaces import LocalWorkspaceBackend, Workspace
|
|
20
|
+
from pydantic_ai_harness.subagents import SubAgents
|
|
21
|
+
from pydantic_ai_harness.subagents._toolset import SubAgentToolset, _Ended
|
|
22
|
+
|
|
23
|
+
from pcode.delegation import ResumableAgent
|
|
24
|
+
from pcode.error_report import error_message
|
|
25
|
+
from pcode.preferences import SETTINGS, load_preferences
|
|
26
|
+
from pcode.task_worktrees import TaskWorktrees
|
|
27
|
+
from pcode.worktree import WorktreeError, describe, setup_scripts
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
_outcome: ContextVar[str | None] = ContextVar("isolated_delegation_outcome", default=None)
|
|
31
|
+
|
|
32
|
+
# Setup hooks run with no model watching and their output discarded, so a hang
|
|
33
|
+
# is invisible until the user gives up. Room for a cold dependency install.
|
|
34
|
+
SETUP_TIMEOUT_SECONDS = 900
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def isolation_enabled() -> bool:
|
|
38
|
+
"""Require explicit worker opt-in as well as the active worktree preference.
|
|
39
|
+
|
|
40
|
+
Never under a remote profile: a task worktree runs the project's
|
|
41
|
+
`worktree-setup` outside the sandbox, from a branch the model can commit to.
|
|
42
|
+
"""
|
|
43
|
+
from pcode import remote_profile
|
|
44
|
+
|
|
45
|
+
if remote_profile.active() is not None:
|
|
46
|
+
return False
|
|
47
|
+
preferences = load_preferences()
|
|
48
|
+
return all(
|
|
49
|
+
preferences.get(key, SETTINGS[key].default) == "on"
|
|
50
|
+
for key in ("worktree", "worker_isolation")
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _joined(task):
|
|
55
|
+
"""Do not abandon a mutation/cleanup thread when the caller is cancelled."""
|
|
56
|
+
cancelled = False
|
|
57
|
+
while not task.done():
|
|
58
|
+
try:
|
|
59
|
+
await asyncio.shield(task)
|
|
60
|
+
except asyncio.CancelledError:
|
|
61
|
+
cancelled = True
|
|
62
|
+
try:
|
|
63
|
+
result = task.result()
|
|
64
|
+
except Exception:
|
|
65
|
+
if cancelled:
|
|
66
|
+
raise asyncio.CancelledError from None
|
|
67
|
+
raise
|
|
68
|
+
if cancelled:
|
|
69
|
+
raise asyncio.CancelledError
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def _finish(store, task_id, status, summary):
|
|
74
|
+
return await _joined(
|
|
75
|
+
asyncio.create_task(asyncio.to_thread(store.finish, task_id, status, summary, wait=True))
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def _stop_setup(process):
|
|
80
|
+
# Setup owns a whole process group, including grandchildren holding pipes.
|
|
81
|
+
# Always kill the group after the grace period, even if its leader exited.
|
|
82
|
+
try:
|
|
83
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
84
|
+
except ProcessLookupError:
|
|
85
|
+
pass
|
|
86
|
+
try:
|
|
87
|
+
await asyncio.wait_for(process.wait(), timeout=1)
|
|
88
|
+
except asyncio.TimeoutError:
|
|
89
|
+
pass
|
|
90
|
+
finally:
|
|
91
|
+
try:
|
|
92
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
93
|
+
except ProcessLookupError:
|
|
94
|
+
pass
|
|
95
|
+
await process.wait()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def _setup(tree):
|
|
99
|
+
"""Use the trusted setup policy, but own cancellable subprocesses per task."""
|
|
100
|
+
env = {
|
|
101
|
+
**os.environ,
|
|
102
|
+
"PCODE_MAIN": str(tree.main),
|
|
103
|
+
"PCODE_WORKTREE": str(tree.path),
|
|
104
|
+
"PCODE_BRANCH": tree.branch,
|
|
105
|
+
}
|
|
106
|
+
for script in setup_scripts(tree):
|
|
107
|
+
command = [str(script)] if os.access(script, os.X_OK) else ["sh", str(script)]
|
|
108
|
+
launch = asyncio.create_task(
|
|
109
|
+
asyncio.create_subprocess_exec(
|
|
110
|
+
*command,
|
|
111
|
+
cwd=tree.path,
|
|
112
|
+
env=env,
|
|
113
|
+
start_new_session=True,
|
|
114
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
115
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
process = None
|
|
119
|
+
try:
|
|
120
|
+
# If cancelled during spawn, join it to obtain and reap its process.
|
|
121
|
+
process = await asyncio.shield(launch)
|
|
122
|
+
code = await process.wait()
|
|
123
|
+
if code:
|
|
124
|
+
raise WorktreeError(f"{script} exited {code}")
|
|
125
|
+
except asyncio.CancelledError:
|
|
126
|
+
if process is None:
|
|
127
|
+
try:
|
|
128
|
+
await _joined(launch)
|
|
129
|
+
except asyncio.CancelledError:
|
|
130
|
+
pass
|
|
131
|
+
process = launch.result()
|
|
132
|
+
await _joined(asyncio.create_task(_stop_setup(process)))
|
|
133
|
+
raise
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _worker_slots() -> asyncio.Semaphore | None:
|
|
137
|
+
limit = int(
|
|
138
|
+
load_preferences().get("worker_concurrency", SETTINGS["worker_concurrency"].default)
|
|
139
|
+
)
|
|
140
|
+
return asyncio.Semaphore(limit) if limit else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(kw_only=True)
|
|
144
|
+
class WorkspaceSubAgents(SubAgents):
|
|
145
|
+
workspace: Path
|
|
146
|
+
worker_factory: Any
|
|
147
|
+
worker_slots: asyncio.Semaphore | None = field(
|
|
148
|
+
default_factory=_worker_slots, repr=False, compare=False
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def get_toolset(self):
|
|
152
|
+
return WorkspaceSubAgentToolset(
|
|
153
|
+
workspace=self.workspace,
|
|
154
|
+
worker_factory=self.worker_factory,
|
|
155
|
+
worker_slots=self.worker_slots,
|
|
156
|
+
agents=self._by_name,
|
|
157
|
+
forward_usage=self.forward_usage,
|
|
158
|
+
inherit_tools=self.inherit_tools,
|
|
159
|
+
shared_capabilities=self.shared_capabilities,
|
|
160
|
+
event_stream_handler=self.event_stream_handler,
|
|
161
|
+
tool_name=self.tool_name,
|
|
162
|
+
tool_retries=self.tool_retries,
|
|
163
|
+
contain_errors=self.contain_errors,
|
|
164
|
+
call_counts=self._call_counts,
|
|
165
|
+
models=self._menu,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def get_instructions(self):
|
|
169
|
+
return (super().get_instructions() or "") + (
|
|
170
|
+
"\nWorker workspace_mode defaults to auto: shared unless both worktree=on and "
|
|
171
|
+
"worker_isolation=on in the active workspace's config. worker_isolation defaults "
|
|
172
|
+
"to off; worktree alone never isolates workers. Isolation requires a clean tracked "
|
|
173
|
+
"parent checkout; commit a checkpoint first, never stash or silently switch modes. "
|
|
174
|
+
"Use shared explicitly for work that needs the live parent files; coordinate writes. "
|
|
175
|
+
"Specialized delegates retain their own tools and use shared mode. "
|
|
176
|
+
"Isolated results are branches, not changes to your checkout. Review them, then "
|
|
177
|
+
"integrate_task into this parent branch and verify the combined changes. "
|
|
178
|
+
"Use list_task_worktrees to recover task IDs after restart. "
|
|
179
|
+
"Never push worker branches or merge them directly into mainline. "
|
|
180
|
+
"Ask the user before discard_task(confirm=True) destroys unintegrated work."
|
|
181
|
+
+ (
|
|
182
|
+
# Harness's listing above reads as the only keys allowed.
|
|
183
|
+
"\nBesides the keys listed, `model` takes any other provider:model name."
|
|
184
|
+
if self._menu
|
|
185
|
+
else ""
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class WorkspaceSubAgentToolset(SubAgentToolset):
|
|
191
|
+
def __init__(self, *, workspace: Path, worker_factory, worker_slots, **kwargs):
|
|
192
|
+
self.workspace = workspace
|
|
193
|
+
self.worker_factory = worker_factory
|
|
194
|
+
self.worker_slots = worker_slots
|
|
195
|
+
super().__init__(**kwargs)
|
|
196
|
+
self.add_function(self.integrate_task)
|
|
197
|
+
self.add_function(self.discard_task)
|
|
198
|
+
self.add_function(self.list_task_worktrees)
|
|
199
|
+
|
|
200
|
+
def _prepare_delegate(self, ctx, tool_def):
|
|
201
|
+
"""Offer `model` as any `provider:model` name, not only a /subagents key.
|
|
202
|
+
|
|
203
|
+
The description is static, so the schema stays cache-stable however
|
|
204
|
+
many names get resolved on demand.
|
|
205
|
+
"""
|
|
206
|
+
tool_def = super()._prepare_delegate(ctx, tool_def)
|
|
207
|
+
if tool_def is None:
|
|
208
|
+
return None
|
|
209
|
+
schema = {**tool_def.parameters_json_schema}
|
|
210
|
+
schema["properties"] = {
|
|
211
|
+
**schema.get("properties", {}),
|
|
212
|
+
"model": {
|
|
213
|
+
"type": "string",
|
|
214
|
+
"description": (
|
|
215
|
+
"Optional model to run the sub-agent on, as provider:model (any "
|
|
216
|
+
"/model name), such as for a second opinion from another model. "
|
|
217
|
+
"Omit it to use the sub-agent's default, the session's model."
|
|
218
|
+
),
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
return replace(tool_def, parameters_json_schema=schema)
|
|
222
|
+
|
|
223
|
+
async def _add_model(self, agent_name: str, key: str | None) -> None:
|
|
224
|
+
"""Put a name off the /subagents menu on it, resolved the way /btw resolves one.
|
|
225
|
+
|
|
226
|
+
Harness's own key check then runs unchanged. A delegate restricted to
|
|
227
|
+
some keys is left for that check to refuse, without resolving anything.
|
|
228
|
+
"""
|
|
229
|
+
sub_agent = self._agents.get(agent_name)
|
|
230
|
+
if key is None or key in self._models or sub_agent is None or sub_agent.models:
|
|
231
|
+
return
|
|
232
|
+
from pydantic_ai_harness.subagents import ModelOption
|
|
233
|
+
|
|
234
|
+
from pcode.agent import side_model
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
# Reads logins and preferences from disk.
|
|
238
|
+
chosen = await asyncio.to_thread(side_model, key)
|
|
239
|
+
except ValueError as error:
|
|
240
|
+
raise ModelRetry(str(error)) from error
|
|
241
|
+
except Exception as error:
|
|
242
|
+
# A broken login file is the model's to work around, not a failed turn.
|
|
243
|
+
raise ModelRetry(f"Cannot use {key}: {type(error).__name__}: {error}") from error
|
|
244
|
+
# Kept for later delegations in this session; the schema never lists it.
|
|
245
|
+
self._models[key] = ModelOption(chosen.model, settings=chosen.settings)
|
|
246
|
+
|
|
247
|
+
async def delegate_task(
|
|
248
|
+
self,
|
|
249
|
+
ctx: RunContext,
|
|
250
|
+
agent_name: str,
|
|
251
|
+
task: str,
|
|
252
|
+
workspace_mode: Literal["auto", "isolated", "shared"] = "auto",
|
|
253
|
+
model: str | None = None,
|
|
254
|
+
purpose: str = "",
|
|
255
|
+
) -> Any:
|
|
256
|
+
"""Delegate a self-contained task; the child does not see this conversation.
|
|
257
|
+
|
|
258
|
+
auto shares the parent's live files unless both worktree=on and
|
|
259
|
+
worker_isolation=on in the active config. isolated requires both settings
|
|
260
|
+
and clean tracked files, creates a branch at the parent's HEAD, and returns
|
|
261
|
+
a persistent task record. shared is not read-only. Specialized agents only
|
|
262
|
+
support shared mode. Review isolated results before integrate_task.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
purpose: What the sub-agent is doing, at most 8 words, present tense
|
|
266
|
+
(e.g. "fixing the /btw cache prefix"). It labels the delegation
|
|
267
|
+
in the user's task list; the sub-agent never sees it.
|
|
268
|
+
"""
|
|
269
|
+
# `purpose` is display-only: the event layer reads it from the call's arguments.
|
|
270
|
+
if agent_name == "worker" and self.worker_slots is not None:
|
|
271
|
+
async with self.worker_slots:
|
|
272
|
+
return await self._delegate(ctx, agent_name, task, workspace_mode, model)
|
|
273
|
+
return await self._delegate(ctx, agent_name, task, workspace_mode, model)
|
|
274
|
+
|
|
275
|
+
async def _delegate(self, ctx, agent_name, task, workspace_mode, model):
|
|
276
|
+
await self._add_model(agent_name, model)
|
|
277
|
+
isolated = workspace_mode == "isolated" or (
|
|
278
|
+
workspace_mode == "auto" and agent_name == "worker" and isolation_enabled()
|
|
279
|
+
)
|
|
280
|
+
if not isolated:
|
|
281
|
+
return await super().delegate_task(ctx, agent_name, task, model)
|
|
282
|
+
if not isolation_enabled():
|
|
283
|
+
raise ModelRetry(
|
|
284
|
+
"Isolated delegation requires both worktree=on and worker_isolation=on "
|
|
285
|
+
"in the active config. Use shared mode without worker isolation."
|
|
286
|
+
)
|
|
287
|
+
if agent_name != "worker":
|
|
288
|
+
raise ModelRetry("Only the built-in worker supports isolated workspaces; use shared.")
|
|
289
|
+
key = self._resolve_model_key(agent_name, self._agents[agent_name], model)
|
|
290
|
+
try:
|
|
291
|
+
store = TaskWorktrees(self.workspace)
|
|
292
|
+
creation = asyncio.create_task(asyncio.to_thread(store.create, wait=True))
|
|
293
|
+
try:
|
|
294
|
+
record = await _joined(creation)
|
|
295
|
+
except asyncio.CancelledError:
|
|
296
|
+
if not creation.cancelled() and creation.exception() is None:
|
|
297
|
+
await _finish(
|
|
298
|
+
store,
|
|
299
|
+
creation.result().task_id,
|
|
300
|
+
"cancelled",
|
|
301
|
+
"Delegation cancelled during creation; checkout preserved.",
|
|
302
|
+
)
|
|
303
|
+
raise
|
|
304
|
+
except WorktreeError as error:
|
|
305
|
+
raise ModelRetry(str(error)) from error
|
|
306
|
+
token = _outcome.set(None)
|
|
307
|
+
try:
|
|
308
|
+
# Timeout or failure preserves the artifact below.
|
|
309
|
+
try:
|
|
310
|
+
async with asyncio.timeout(SETUP_TIMEOUT_SECONDS):
|
|
311
|
+
await _setup(describe(Path(record.worktree)))
|
|
312
|
+
except TimeoutError:
|
|
313
|
+
raise WorktreeError(f"Worktree setup exceeded {SETUP_TIMEOUT_SECONDS}s") from None
|
|
314
|
+
async with self.worker_factory(Path(record.worktree)) as worker:
|
|
315
|
+
prompt = (
|
|
316
|
+
f"{task}\n\nYou are working in an isolated task worktree.\n"
|
|
317
|
+
f"Task ID: {record.task_id}\nBase commit: {record.base_commit}\n"
|
|
318
|
+
f"Branch: {record.branch}\nWorkspace: {record.worktree}\n"
|
|
319
|
+
"Edit and test here. Commit completed changes on this branch. "
|
|
320
|
+
"Do not push, merge into another checkout, or remove this worktree. "
|
|
321
|
+
"The parent will review and integrate your result. Stop all jobs before "
|
|
322
|
+
"finishing; remaining jobs will be terminated when delegation ends."
|
|
323
|
+
)
|
|
324
|
+
# Harness hands a delegate the parent's `ctx.workspace`; this
|
|
325
|
+
# one works in its own checkout instead.
|
|
326
|
+
child_ctx = replace(
|
|
327
|
+
ctx, workspace=Workspace(LocalWorkspaceBackend(record.worktree))
|
|
328
|
+
)
|
|
329
|
+
output = await self._run_delegation(
|
|
330
|
+
child_ctx,
|
|
331
|
+
agent_name,
|
|
332
|
+
replace(self._agents[agent_name], agent=worker),
|
|
333
|
+
task=prompt,
|
|
334
|
+
key=key,
|
|
335
|
+
)
|
|
336
|
+
status = "completed" if _outcome.get() == "ok" else "failed"
|
|
337
|
+
record = await _finish(store, record.task_id, status, output)
|
|
338
|
+
except asyncio.CancelledError:
|
|
339
|
+
await _finish(
|
|
340
|
+
store, record.task_id, "cancelled", "Delegation cancelled; partial work preserved."
|
|
341
|
+
)
|
|
342
|
+
raise
|
|
343
|
+
except Exception as error:
|
|
344
|
+
# Preserve the artifact even when Harness reports a retry or setup fails.
|
|
345
|
+
# A contained crash arrives as Harness's retry, already described.
|
|
346
|
+
summary = str(error) if isinstance(error, ModelRetry) else None
|
|
347
|
+
record = await _finish(
|
|
348
|
+
store, record.task_id, "failed", summary or f"{type(error).__name__}: {error}"
|
|
349
|
+
)
|
|
350
|
+
finally:
|
|
351
|
+
_outcome.reset(token)
|
|
352
|
+
return {
|
|
353
|
+
**asdict(record),
|
|
354
|
+
"workspace_mode": "isolated",
|
|
355
|
+
"verification": "Worker-reported; see summary.",
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async def _run_delegation(self, ctx, agent_name, sub_agent, *, task, key):
|
|
359
|
+
resumable = replace(sub_agent, agent=ResumableAgent(sub_agent.agent))
|
|
360
|
+
return await super()._run_delegation(ctx, agent_name, resumable, task=task, key=key)
|
|
361
|
+
|
|
362
|
+
def _crash_outcome(self, agent_name, sub_agent, exc):
|
|
363
|
+
"""Harness's containment, minus the raw exception text and the traceback.
|
|
364
|
+
|
|
365
|
+
Harness puts `str(exc)` in the parent's retry message, which the model,
|
|
366
|
+
the transcript and the panel all see; SDK messages can carry provider
|
|
367
|
+
bodies, so describe it the way a failed turn is described instead. Its
|
|
368
|
+
warning prints a traceback over the terminal, which has no log handler.
|
|
369
|
+
"""
|
|
370
|
+
contain = sub_agent.contain_errors
|
|
371
|
+
if not (self._contain_errors if contain is None else contain):
|
|
372
|
+
raise exc
|
|
373
|
+
logger.debug("Contained crash from sub-agent %r", agent_name, exc_info=exc)
|
|
374
|
+
return _Ended(
|
|
375
|
+
outcome="contained",
|
|
376
|
+
output=(
|
|
377
|
+
f"Sub-agent {agent_name!r} crashed: {error_message(exc)} "
|
|
378
|
+
"Treat this as a recoverable failure and decide from existing evidence."
|
|
379
|
+
),
|
|
380
|
+
cause=exc,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
async def _settle(self, *args, **kwargs):
|
|
384
|
+
ended = await super()._settle(*args, **kwargs)
|
|
385
|
+
_outcome.set(ended.outcome)
|
|
386
|
+
return ended
|
|
387
|
+
|
|
388
|
+
async def integrate_task(self, task_id: str) -> dict:
|
|
389
|
+
"""Merge a completed task into its recorded parent, never mainline.
|
|
390
|
+
|
|
391
|
+
Requires clean parent and child checkouts. Conflicts remain in the parent;
|
|
392
|
+
resolve and commit them, then retry. The child is kept for worktree cleanup.
|
|
393
|
+
"""
|
|
394
|
+
try:
|
|
395
|
+
record = await _joined(
|
|
396
|
+
asyncio.create_task(
|
|
397
|
+
asyncio.to_thread(TaskWorktrees(self.workspace).integrate, task_id)
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
return asdict(record)
|
|
401
|
+
except WorktreeError as error:
|
|
402
|
+
raise ModelRetry(str(error)) from error
|
|
403
|
+
|
|
404
|
+
async def discard_task(self, task_id: str, confirm: bool = False) -> dict:
|
|
405
|
+
"""Remove an inactive task checkout and branch.
|
|
406
|
+
|
|
407
|
+
Unintegrated or dirty work requires confirm=True, only after the user has
|
|
408
|
+
explicitly approved discarding it. Active tasks cannot be discarded.
|
|
409
|
+
"""
|
|
410
|
+
try:
|
|
411
|
+
record = await _joined(
|
|
412
|
+
asyncio.create_task(
|
|
413
|
+
asyncio.to_thread(TaskWorktrees(self.workspace).discard, task_id, confirm)
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
return asdict(record)
|
|
417
|
+
except WorktreeError as error:
|
|
418
|
+
raise ModelRetry(str(error)) from error
|
|
419
|
+
|
|
420
|
+
async def list_task_worktrees(self) -> list[dict]:
|
|
421
|
+
"""List this parent's persistent worker artifacts, including failed tasks."""
|
|
422
|
+
try:
|
|
423
|
+
return [asdict(record) for record in TaskWorktrees(self.workspace).list()]
|
|
424
|
+
except WorktreeError as error:
|
|
425
|
+
raise ModelRetry(str(error)) from error
|
pcode/job_notices.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Deliver job exits to the model at the next request, so it never polls.
|
|
2
|
+
|
|
3
|
+
The reason a model writes `sleep 30; cat status.json` is that nothing else will
|
|
4
|
+
ever tell it the command finished. Steering already proves the seam exists: a
|
|
5
|
+
capability can append to the request the framework is about to send, between
|
|
6
|
+
model requests and never mid-tool. Completion notices ride the same seam, which
|
|
7
|
+
is why a background job costs no extra model requests and no waiting turns.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pydantic_ai.capabilities import AbstractCapability
|
|
11
|
+
from pydantic_ai.messages import UserPromptPart
|
|
12
|
+
|
|
13
|
+
from pcode.jobs import JobRegistry, format_duration
|
|
14
|
+
|
|
15
|
+
# A failed job's tail rides along with its notice: the model fetches it nearly
|
|
16
|
+
# every time, and a request saved is worth more than the bytes. A success is
|
|
17
|
+
# usually acted on without reading, so it stays a handle.
|
|
18
|
+
FAILURE_TAIL_BYTES = 2_048
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def notice(job, tail: str = "") -> str:
|
|
22
|
+
# `label` rather than `summary`: a notice arrives turns later and has to be
|
|
23
|
+
# read at a glance, so what the job was for beats how it was spelled. The
|
|
24
|
+
# command is still one `job_output` away.
|
|
25
|
+
text = f"[{job.id}] {job.label()} → {job.outcome()} after {format_duration(job.elapsed)}."
|
|
26
|
+
if job.stopped:
|
|
27
|
+
return text + " It was stopped, so its output may be incomplete."
|
|
28
|
+
if tail.strip():
|
|
29
|
+
return (
|
|
30
|
+
text
|
|
31
|
+
+ f' Read all of its output with job_output("{job.id}"). Last output:\n'
|
|
32
|
+
+ tail.rstrip("\n")
|
|
33
|
+
)
|
|
34
|
+
return text + f' Read its output with job_output("{job.id}").'
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def notice_for(jobs: JobRegistry, job) -> str:
|
|
38
|
+
"""The notice with a failed job's output tail attached."""
|
|
39
|
+
tail = ""
|
|
40
|
+
if not job.stopped and job.exit_code not in (None, 0):
|
|
41
|
+
tail, _ = jobs.read_output(job, max_bytes=FAILURE_TAIL_BYTES)
|
|
42
|
+
return notice(job, tail)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class JobNotices(AbstractCapability):
|
|
46
|
+
def __init__(self, jobs: JobRegistry) -> None:
|
|
47
|
+
self.jobs = jobs
|
|
48
|
+
|
|
49
|
+
async def before_model_request(self, ctx, request_context):
|
|
50
|
+
# Appended to the framework's own request, so tool results stay ahead
|
|
51
|
+
# of the notice and the notice is persisted with the conversation.
|
|
52
|
+
for job in self.jobs.take_announcements("model"):
|
|
53
|
+
request_context.messages[-1].parts.append(UserPromptPart(notice_for(self.jobs, job)))
|
|
54
|
+
return request_context
|