Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
52b1e7d
fix(context): reject truncated conversation summaries
KaiEureka Oct 25, 2026
1de2e2b
fix(workflow): account for live candidate token usage
KaiEureka Oct 25, 2026
efb8411
fix(session): preserve snapshot runtime ownership and policies
KaiEureka Oct 25, 2026
171aabf
fix(workflows): preserve authored execution and artifact delivery
KaiEureka Oct 25, 2026
54b4136
fix(cli): decline questions from interrupted turns
KaiEureka Oct 25, 2026
aacbfec
fix(weave): execute checks and collect pytest target evidence
KaiEureka Oct 25, 2026
13f693b
fix(storage): preserve developer messages in session snapshots
KaiEureka Oct 25, 2026
df8d17b
fix(git): accept disabled sparse checkout settings
KaiEureka Oct 25, 2026
34ac00a
fix: consolidate audited runtime and workflow repairs
KaiEureka Oct 25, 2026
b954974
fix(cli): forward workflow run identity for continuation
KaiEureka Oct 25, 2026
02def75
fix(weave): keep wrapped printing probes unverified
KaiEureka Oct 25, 2026
bc59377
fix(weave): keep shell grouping probes unverified
KaiEureka Oct 25, 2026
bcc5bb0
fix(weave): preserve runner and shell command boundaries
KaiEureka Oct 25, 2026
f4ce28f
fix: include verified Weave execution repairs
KaiEureka Oct 25, 2026
7f766fe
test(cli): preserve Unicode paths in English fixtures
KaiEureka Oct 25, 2026
65d6cc2
fix(snapshot): rebind workflow owners after copying callbacks
KaiEureka Oct 25, 2026
f48c114
fix: complete cross-version audit regressions
KaiEureka Oct 25, 2026
1ff06ad
fix(snapshot): preserve environment provider bindings
KaiEureka Oct 25, 2026
f02f3fe
fix: include native snapshot environment repair
KaiEureka Oct 25, 2026
77d5b73
fix: isolate workflow snapshot observation configuration
KaiEureka Oct 25, 2026
927f1a1
fix: include isolated snapshot observation configuration
KaiEureka Oct 25, 2026
2110a57
fix(snapshot): share model transport ownership across snapshots
KaiEureka Oct 25, 2026
8ec1fe2
fix: retain native model lifetime across session snapshots
KaiEureka Oct 25, 2026
38cba5a
fix: preserve effective snapshot enforcement settings
KaiEureka Oct 26, 2026
cb9fcc5
fix(llm): restore developer instructions for Anthropic
KaiEureka Oct 26, 2026
18b6f68
fix: preserve effective snapshot budgets in audit repairs
KaiEureka Oct 26, 2026
a96ca68
fix: include Anthropic developer history repair
KaiEureka Oct 26, 2026
c79ccab
fix(workflow): preserve returned exception results
KaiEureka Oct 26, 2026
4101507
fix: preserve collection result values in audit repairs
KaiEureka Oct 26, 2026
8604d8d
test: synchronize cancellation-resistant workflow cleanup
KaiEureka Oct 26, 2026
3a0b4ff
test: stabilize workflow cleanup validation
KaiEureka Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/session-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,25 @@ the caller's source remains an explicit operation.

The Workflow CLI accepts `--isolation shared` or `--isolation worktree`.

## Session snapshots

`opencollab.bootstrap.snapshot_session(session)` creates independent session
state and a forked environment under the [snapshot collaborator requirements](migrations/0.5.0.md).
The source and its successful snapshots share the model transport. When OC
created that transport, each session holds an ownership reference and
`await session.aclose()` releases its reference. The final owner closes the
transport once. A snapshot can continue model requests after its source or
sibling closes. A model injected through `build_session(llm=...)` remains owned
by its caller, who closes it after all sessions finish.

The snapshot acquires its model reference after its state and runtime
collaborators have copied successfully. Failed construction cleans up its
forked environment. Shared model ownership retains a reference count, allowing
a closed source session to be reclaimed while its snapshots continue. Creating
a new snapshot from a session that has released its model ownership raises
`SnapshotSessionError`. Each session's environment retains its own cleanup
operation.

## Custom environment providers

Pass `environment_providers={"custom": factory}` to `OpenCollab`, then select
Expand Down
14 changes: 14 additions & 0 deletions docs/session-isolation.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ topology:

Workflow CLI 支持 `--isolation shared` 和 `--isolation worktree`。

## Session 快照

`opencollab.bootstrap.snapshot_session(session)` 按照[快照协作者要求](migrations/0.5.0.md),
创建独立的会话状态和分叉环境。源会话与成功创建的快照共享模型传输。
OC 创建的传输由每个会话分别持有一份所有权引用,
`await session.aclose()` 释放该会话的引用,最后一个拥有者关闭传输一次。
源会话或兄弟快照关闭后,存活快照仍可继续请求模型。
通过 `build_session(llm=...)` 注入的模型由调用者拥有,调用者在所有会话结束后关闭它。

快照在状态和运行时协作者全部复制成功后取得模型引用,构建失败会清理已分叉的环境。
共享模型的所有权通过引用计数保存,源会话关闭后可以回收,存活快照继续运行。
从已释放模型所有权的会话创建新快照会抛出 `SnapshotSessionError`。
各会话的环境分别执行自己的清理操作。

## 自定义环境提供器

向 `OpenCollab` 传入 `environment_providers={"custom": factory}`,随后使用 `isolation="custom"`。每个工厂通过关键字参数接收 `workspace`、`base_environment`、`owned_save_dir`、`enable_adoption` 和 `owned_output_paths`。
Expand Down
10 changes: 9 additions & 1 deletion docs/weave.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ test output. An executable probe uses a `command` and its `expected_output`, as
shown above. Include assertions in the probe so the expected output follows
the behavior being checked.

Weave adds `-rA` to recognized pytest invocations to retain the executed test
nodes, including ordinary `python -m pytest test_result.py -q` checks. The
verification report keeps the supplied `command` and records `executed_command`
when reporting flags were added. Shell combinations outside the supported test
parser require an application verifier or an assertion script with
`expected_output`.

## Run through the public SDK

This script uses the installed package, creates ordinary files, and verifies
Expand Down Expand Up @@ -190,6 +197,7 @@ rounds. `max_repair_rounds` and `stagnant_round_limit` bound repair attempts.
An adapter's `save_state(data)` hook receives independent JSON snapshots for
storage. Supply a saved snapshot as the JSON `state` input or through
`WeaveState(saved_snapshot)` with `run_weave`. Reuse the saved `run_id`
when calling the SDK. Generated groups continue at their checks, checked groups
when calling the SDK or pass it as `workflow run weave --run-id RUN_ID` through
the CLI. Generated groups continue at their checks, checked groups
retain their generation results, and consumed repair rounds remain counted.
The saved group fields must agree with the supplied groups.
35 changes: 35 additions & 0 deletions opencollab/adapters/_docker_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import posixpath
import shlex
import shutil
import stat
import tempfile
from contextlib import asynccontextmanager
from pathlib import Path
Expand All @@ -14,8 +15,10 @@
from opencollab.adapters._env_docker import DockerEnvironment
from opencollab.adapters._env_local import LocalEnvironment
from opencollab.adapters._env_vm import VMEnvironment
from opencollab.adapters._env_worktree import WorktreeEnvironment
from opencollab.adapters._git_patch_bytes import _complete, _patch_temporary
from opencollab.adapters._team_candidate_environment import _TeamCandidateEnvironment
from opencollab.adapters.safe_anchored_files import open_regular_read_at
from opencollab.application.async_timeout import await_owned_operation


Expand All @@ -40,6 +43,38 @@ async def _copy_to_host(environment: Any, source: str, target: Path, *, director
raise OSError("Docker source workspace copy failed")


async def _copy_artifact_to_host(environment: Any, source: str, target: Path) -> None:
relative = (
posixpath.relpath(source, environment.workspace) if posixpath.isabs(source) else posixpath.normpath(source)
)
if relative == ".." or relative.startswith("../"):
raise PermissionError("artifact path escapes workspace")
source = posixpath.join(environment.workspace, relative)
delegate = environment._delegate if isinstance(environment, _TeamCandidateEnvironment) else environment
if isinstance(delegate, WorktreeEnvironment):
delegate._ensure_active()
if delegate._local_env is None:
await delegate.setup()
delegate = delegate._local_env
if isinstance(delegate, LocalEnvironment):
def copy() -> None:
with open_regular_read_at(
delegate._root_fd(), delegate.workspace, delegate._relative_path(source),
) as handle, target.open("wb") as output:
shutil.copyfileobj(handle, output)

await delegate._run_file_operation(copy)
return
if isinstance(delegate, DockerEnvironment):
source = await delegate._contained_file_path(source)
if isinstance(delegate, (DockerEnvironment, VMEnvironment)):
await _copy_to_host(delegate, source, target)
if not stat.S_ISREG(target.lstat().st_mode):
raise OSError("artifact transfer requires a regular file")
return
target.write_text(await environment.read_file(source), encoding="utf-8")


def _remove_marker(path: Path) -> None:
marker = path / ".git"
if marker.is_dir() and not marker.is_symlink():
Expand Down
4 changes: 2 additions & 2 deletions opencollab/adapters/_env_worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -679,12 +679,12 @@ async def _ensure_directory_copy_changes_exported(self) -> None:
"call get_diff() and deliver its artifact first"
)

async def exec_cmd(self, cmd: str, timeout: float = 120.0) -> ExecResult:
async def exec_cmd(self, cmd: str, timeout: float = 120.0, *, input_bytes: bytes | None = None) -> ExecResult:
self._ensure_active()
if self._local_env is None:
await self.setup()
assert self._local_env is not None
return await self._local_env.exec_cmd(cmd, timeout)
return await self._local_env.exec_cmd(cmd, timeout, input_bytes=input_bytes)

async def read_file(self, path: str) -> str:
self._ensure_active()
Expand Down
15 changes: 13 additions & 2 deletions opencollab/adapters/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import sys
import uuid
from collections.abc import Awaitable
from contextvars import ContextVar
from typing import Any, Optional

import typer
Expand Down Expand Up @@ -366,10 +367,18 @@ async def _run(
# ``--yolo`` only auto-approves risky commands; a human is still present, so
# the ask-user tool stays interactive — its question takes the shared input
# line ahead of whatever the user was typing.
turn_cancel: ContextVar[asyncio.Event | None] = ContextVar("cli_turn_cancel", default=None)

async def read_question(text: str) -> str:
cancel = turn_cancel.get()
if cancel is not None and cancel.is_set():
raise EOFError
return await prompt.ask(text)

permission_policy = None
if not yolo:
permission_policy = TuiPermissionPolicy(read_line=prompt.ask)
ask_policy = TuiAskUserPolicy(read_line=prompt.ask)
permission_policy = TuiPermissionPolicy(read_line=read_question)
ask_policy = TuiAskUserPolicy(read_line=read_question)

event_sink = TuiEventSink(tui)
events_file = os.environ.get("OPENCOLLAB_EVENTS_FILE")
Expand Down Expand Up @@ -418,13 +427,15 @@ async def turn(line: str, target_aid: int, cancel: asyncio.Event) -> None:
tui.record_user_message(target_aid, line)
tui.start_turn(target_aid)
failure: SchedulerTurnError | None = None
cancel_token = turn_cancel.set(cancel)
try:
await scheduler.run_turn(target_aid, line, cancel_event=cancel)
except SchedulerTurnError as exc:
failure = exc
except KeyboardInterrupt:
pass
finally:
turn_cancel.reset(cancel_token)
# Flush the answering agent's unprinted tail when focus moved to a teammate.
# Switching focus would reprint rows already in scrollback.
tui.settle_turn(
Expand Down
2 changes: 2 additions & 0 deletions opencollab/adapters/cli/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def run_cmd(
api_key: Optional[str] = typer.Option(None, "--api-key", help="API key (default from config)"),
base_url: Optional[str] = typer.Option(None, "--base-url", help="API base URL (default from config)"),
workspace: str = typer.Option(".", "--workspace", "-w", help="Working directory"),
run_id: Optional[str] = typer.Option(None, "--run-id", help="Original run identity for workflow continuation"),
budget: Optional[int] = typer.Option(
None,
"--budget",
Expand Down Expand Up @@ -177,6 +178,7 @@ def run_cmd(
agent_profile=agent_profile,
**({"isolation": isolation} if isolation is not None else {}),
**({"cleanup_timeout": cleanup_timeout} if cleanup_timeout is not None else {}),
**({"run_id": run_id} if run_id is not None else {}),
save_dir=save_dir,
trace=trace,
return_details=True,
Expand Down
26 changes: 13 additions & 13 deletions opencollab/adapters/git_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,18 @@ def _git_command_and_config_guard(*, allow_core_worktree: bool = False) -> tuple
"for config_scope in $config_scopes; do "
f"unsafe_config=$({git_command} config \"$config_scope\" --includes --name-only --get-regexp "
"'^(diff\\..*\\.(command|textconv)|filter\\..*|"
f"core\\.(excludesfile|sparsecheckout|sparsecheckoutcone{worktree_setting}))$' "
f"core\\.(excludesfile{worktree_setting}))$' "
"2>/dev/null); "
'config_rc=$?; if [ "$config_rc" -eq 0 ]; then '
"echo \"unsafe repository Git configuration: $unsafe_config\" >&2; exit 125; "
'elif [ "$config_rc" -ne 1 ]; then exit "$config_rc"; fi; '
f'fsmonitor=$({git_command} config "$config_scope" --includes --type=bool '
"--get core.fsmonitor 2>/dev/null); fsmonitor_rc=$?; "
'if [ "$fsmonitor_rc" -eq 0 ] && [ "$fsmonitor" = false ]; then :; '
'elif [ "$fsmonitor_rc" -ne 1 ]; then '
"echo 'unsafe repository Git configuration: core.fsmonitor' >&2; exit 125; fi; "
"for boolean_key in core.fsmonitor core.sparsecheckout core.sparsecheckoutcone; do "
f'config_value=$({git_command} config "$config_scope" --includes --type=bool '
'--get "$boolean_key" 2>/dev/null); config_rc=$?; '
'if [ "$config_rc" -eq 0 ] && [ "$config_value" = false ]; then :; '
'elif [ "$config_rc" -ne 1 ]; then '
'echo "unsafe repository Git configuration: $boolean_key" >&2; exit 125; fi; '
"done; "
"done; "
f'info_attributes=$({git_command} rev-parse --git-path info/attributes) || exit 125; '
'if [ -L "$info_attributes" ] || { [ -e "$info_attributes" ] && [ ! -f "$info_attributes" ]; }; then '
Expand Down Expand Up @@ -164,13 +166,11 @@ def guarded_staged_diff_command(
``core.attributesFile`` and ``diff.ignoreSubmodules`` are pinned by an
explicit ``-c``, which outranks any repository-local value, so a repository
that marks a file undiffable or hides a gitlink change cannot do either
here. ``core.fsmonitor`` is judged by its value: a repository that turns a
monitor off is hardening itself and is allowed through, while one that turns
a monitor on is refused, because a monitor that lies about what changed can
only be trusted or refused, never overridden. Everything else that could
bend the reading -- a redirected working tree, a sparse checkout, an
excludes file, and the clean/smudge filters and external diff drivers a
tracked ``.gitattributes`` can still reach -- is refused outright.
here. ``core.fsmonitor``, ``core.sparsecheckout``, and
``core.sparsecheckoutcone`` accept Git boolean false values or an absent
setting. Enabled or malformed values are refused. Redirected working trees,
excludes files, clean/smudge filters, and external diff drivers reachable
through tracked ``.gitattributes`` are refused outright.

A refusal exits 125 rather than reporting a patch this command cannot vouch
for.
Expand Down
4 changes: 2 additions & 2 deletions opencollab/adapters/llm/anthropic_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ def convert_to_anthropic_messages(messages: list[dict]) -> tuple[list[str], list
Returns (system_parts, anthropic_messages).

Key conversions:
- role="system" → extracted to system_parts (Anthropic uses top-level system param)
- role="system"/"developer" → extracted to Anthropic's top-level system param
- role="assistant" with tool_calls → assistant with tool_use content blocks
- role="tool" → role="user" with tool_result content blocks (merged if consecutive)
"""
Expand All @@ -558,7 +558,7 @@ def convert_to_anthropic_messages(messages: list[dict]) -> tuple[list[str], list
for message_index, message in enumerate(messages):
role = message.get("role", "")

if role == "system":
if role in {"system", "developer"}:
content = message.get("content", "")
blocks = _normalize_openai_content(
content,
Expand Down
39 changes: 28 additions & 11 deletions opencollab/adapters/safe_anchored_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
import os
import stat
import uuid
from collections.abc import Callable
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from typing import BinaryIO

from opencollab.adapters._env_base import TextFileRange
Expand Down Expand Up @@ -116,15 +117,13 @@ def open_parent_beneath(
os.close(current_fd)
raise

def read_regular_bytes_at(
@contextmanager
def open_regular_read_at(
root_fd: int,
root_path: str | os.PathLike[str],
path: str | os.PathLike[str],
*,
max_bytes: int,
) -> bytes:
"""Read a bounded regular file beneath a pinned directory descriptor."""
limit = _require_limit(max_bytes)
) -> Iterator[BinaryIO]:
"""Open a regular file beneath a pinned directory without following symlinks."""
parent_fd, name = open_parent_beneath(
root_fd,
root_path,
Expand All @@ -141,6 +140,28 @@ def read_regular_bytes_at(
opened = os.fstat(fd)
if not stat.S_ISREG(opened.st_mode):
raise OSError(f"read target is not a regular file: {path}")
handle = os.fdopen(fd, "rb")
fd = -1
with handle:
yield handle
finally:
if fd >= 0:
os.close(fd)
os.close(parent_fd)


def read_regular_bytes_at(
root_fd: int,
root_path: str | os.PathLike[str],
path: str | os.PathLike[str],
*,
max_bytes: int,
) -> bytes:
"""Read a bounded regular file beneath a pinned directory descriptor."""
limit = _require_limit(max_bytes)
with open_regular_read_at(root_fd, root_path, path) as handle:
fd = handle.fileno()
opened = os.fstat(fd)
if opened.st_size > limit:
raise ValueError(f"read target exceeds {limit}-byte limit: {path}")
chunks: list[bytes] = []
Expand All @@ -155,10 +176,6 @@ def read_regular_bytes_at(
if len(payload) > limit:
raise ValueError(f"read target exceeds {limit}-byte limit: {path}")
return payload
finally:
if fd >= 0:
os.close(fd)
os.close(parent_fd)

def read_regular_text_range_at(
root_fd: int,
Expand Down
4 changes: 2 additions & 2 deletions opencollab/adapters/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ def write(self, text: str) -> int:
return len(text)

class SessionStore:
allowed_roles = {"system", "user", "assistant", "tool"}
allowed_roles = {"system", "developer", "user", "assistant", "tool"}

def has_snapshot(self, path: str) -> bool:
"""Return whether a base or recoverable journal sidecar is present."""
Expand Down Expand Up @@ -296,7 +296,7 @@ def _validate_messages(self, messages: list[dict[str, Any]]) -> None:
role = msg.get("role")
if role not in self.allowed_roles:
raise ValueError(f"Invalid message role at position {lineno}: {role}")
if role in {"system", "user"}:
if role in {"system", "developer", "user"}:
self._validate_content(msg, lineno)
elif role == "assistant":
tool_calls = msg.get("tool_calls")
Expand Down
Loading
Loading