optio-opencode 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/PKG-INFO +1 -1
  2. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/pyproject.toml +1 -1
  3. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/src/optio_opencode/session.py +66 -63
  4. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/src/optio_opencode.egg-info/PKG-INFO +1 -1
  5. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_host_local.py +21 -3
  6. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_host_primitives_remote.py +16 -0
  7. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/README.md +0 -0
  8. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/setup.cfg +0 -0
  9. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/src/optio_opencode/__init__.py +0 -0
  10. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/src/optio_opencode/host_actions.py +0 -0
  11. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/src/optio_opencode/prompt.py +0 -0
  12. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/src/optio_opencode/seed_manifest.py +0 -0
  13. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/src/optio_opencode/snapshots.py +0 -0
  14. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/src/optio_opencode/types.py +0 -0
  15. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/src/optio_opencode.egg-info/SOURCES.txt +0 -0
  16. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
  17. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/src/optio_opencode.egg-info/requires.txt +0 -0
  18. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/src/optio_opencode.egg-info/top_level.txt +0 -0
  19. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_agent_sender_opencode.py +0 -0
  20. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_host_actions.py +0 -0
  21. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_host_primitives_local.py +0 -0
  22. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_host_remote_resume.py +0 -0
  23. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_host_resume.py +0 -0
  24. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_prompt.py +0 -0
  25. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_purge_seed.py +0 -0
  26. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_resume_sentence_opencode.py +0 -0
  27. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_sanity.py +0 -0
  28. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_seed_config.py +0 -0
  29. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_session_blob_hooks.py +0 -0
  30. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_session_hooks.py +0 -0
  31. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_session_local.py +0 -0
  32. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_session_remote.py +0 -0
  33. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_session_resume.py +0 -0
  34. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_session_seed.py +0 -0
  35. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_smart_install.py +0 -0
  36. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_snapshots.py +0 -0
  37. {optio_opencode-0.2.0 → optio_opencode-0.2.1}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-opencode
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Run opencode web as an optio task; local subprocess or remote via SSH.
5
5
  Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optio-opencode"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Run opencode web as an optio task; local subprocess or remote via SSH."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -96,74 +96,76 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
96
96
  # server (for the seed model default) before terminating it.
97
97
  worker_port: int | None = None
98
98
 
99
- # --- resume decision (BEFORE the protocol session starts) -------------
100
- resume_requested = bool(getattr(ctx, "resume", False))
101
- snapshot: dict | None = None
102
- if resume_requested:
103
- snapshot = await load_latest_snapshot(
104
- ctx._db, prefix=ctx._prefix, process_id=ctx.process_id,
105
- )
99
+ # Set by _prepare (the driver runs it after the workdir wipe, before the
100
+ # optio.log tail); read by the body and the teardown finally.
101
+ resuming = False
106
102
 
107
- # Connect + install BEFORE deciding fresh vs resume. The resume path
108
- # needs ``opencode import`` to replay the saved session DB, which
109
- # requires opencode to be installed on the host and resolved to an
110
- # absolute path. Hoisting also lets the fresh path skip the redundant
111
- # ``host.connect()`` later. ``setup_workdir`` is idempotent (mkdir -p)
112
- # and the protocol driver still calls it again for the fresh path —
113
- # harmless. Install progress reports through ``ctx``, so the
114
- # dashboard sees activity from the very first step.
115
103
  await host.connect()
116
- await host.setup_workdir()
117
- opencode_exec = await host_actions.ensure_opencode_installed(
118
- HookContext(ctx, host),
119
- install_if_missing=config.install_if_missing,
120
- install_dir=config.opencode_install_dir,
121
- )
122
104
 
123
- # Resume restore must run BEFORE the protocol session begins, so the
124
- # driver's tail_task does not subscribe to the restored stale optio.log
125
- # (which contains last run's DONE / ERROR events). The body below sees
126
- # ``resuming`` already decided.
127
- resuming = snapshot is not None
128
- if resuming:
129
- await host.remove_file(opencode_db)
130
- try:
131
- await host.restore_workdir(_stream_blob(ctx, snapshot["workdirBlobId"]))
132
- session_bytes_raw = await _read_blob_bytes(ctx, snapshot["sessionBlobId"])
133
- decrypt = config.session_blob_decrypt or (lambda b: b)
134
- session_bytes = decrypt(session_bytes_raw)
135
- await host_actions.opencode_import(
136
- host, opencode_db, session_bytes,
137
- opencode_executable=opencode_exec,
138
- )
139
- # Move the restored log channel out of the way BEFORE the
140
- # protocol driver subscribes its tail. The snapshot tar
141
- # includes optio.log from the previous run; without rotation,
142
- # ``tail -F -n +1`` would re-emit every old DELIVERABLE /
143
- # DONE / ERROR line and the resumed process would terminate
144
- # within seconds of launch. Preserve the historical content
145
- # by appending it to optio.log.old.
146
- await _rotate_optio_log(host)
147
- preserved_session_id = snapshot["sessionId"]
148
- except Exception as resume_exc:
149
- # If the failure was the session-blob decrypt hook raising,
150
- # this indicates the snapshot was tampered with or the
151
- # consumer's keypair changed. Fail loud — silently dropping
152
- # to fresh-start would mask the security-relevant signal.
153
- if "decrypt" in repr(resume_exc).lower() and "blob" in repr(resume_exc).lower():
154
- _LOG.error(
155
- "resume restore failed inside session_blob_decrypt; "
156
- "refusing to fall through to fresh-start. Operator must "
157
- "investigate the snapshot blob.",
158
- )
159
- raise
160
- _LOG.exception(
161
- "resume restore failed; falling back to fresh-start path "
162
- "(Mongo blob preserved for inspection)",
105
+ async def _prepare(host: Host, hook_ctx: HookContext) -> None:
106
+ """Install opencode and restore a resume snapshot.
107
+
108
+ Handed to run_log_protocol_session, which runs it AFTER
109
+ host.setup_workdir() wiped the workdir and BEFORE it subscribes the
110
+ optio.log tail. The resume path needs ``opencode import`` to replay
111
+ the saved session DB (so opencode must be installed + resolved to an
112
+ absolute path first), and the restored optio.log is rotated away below
113
+ before the tail can re-emit its stale DELIVERABLE/DONE/ERROR lines.
114
+ """
115
+ nonlocal opencode_exec, resuming, preserved_session_id
116
+ opencode_exec = await host_actions.ensure_opencode_installed(
117
+ hook_ctx,
118
+ install_if_missing=config.install_if_missing,
119
+ install_dir=config.opencode_install_dir,
120
+ )
121
+
122
+ resume_requested = bool(getattr(ctx, "resume", False))
123
+ snapshot: dict | None = None
124
+ if resume_requested:
125
+ snapshot = await load_latest_snapshot(
126
+ ctx._db, prefix=ctx._prefix, process_id=ctx.process_id,
163
127
  )
128
+
129
+ resuming = snapshot is not None
130
+ if resuming:
164
131
  await host.remove_file(opencode_db)
165
- resuming = False
166
- preserved_session_id = None
132
+ try:
133
+ await host.restore_workdir(_stream_blob(ctx, snapshot["workdirBlobId"]))
134
+ session_bytes_raw = await _read_blob_bytes(ctx, snapshot["sessionBlobId"])
135
+ decrypt = config.session_blob_decrypt or (lambda b: b)
136
+ session_bytes = decrypt(session_bytes_raw)
137
+ await host_actions.opencode_import(
138
+ host, opencode_db, session_bytes,
139
+ opencode_executable=opencode_exec,
140
+ )
141
+ # Move the restored log channel out of the way BEFORE the
142
+ # protocol driver subscribes its tail. The snapshot tar
143
+ # includes optio.log from the previous run; without rotation,
144
+ # ``tail -F -n +1`` would re-emit every old DELIVERABLE /
145
+ # DONE / ERROR line and the resumed process would terminate
146
+ # within seconds of launch. Preserve the historical content
147
+ # by appending it to optio.log.old.
148
+ await _rotate_optio_log(host)
149
+ preserved_session_id = snapshot["sessionId"]
150
+ except Exception as resume_exc:
151
+ # If the failure was the session-blob decrypt hook raising,
152
+ # this indicates the snapshot was tampered with or the
153
+ # consumer's keypair changed. Fail loud — silently dropping
154
+ # to fresh-start would mask the security-relevant signal.
155
+ if "decrypt" in repr(resume_exc).lower() and "blob" in repr(resume_exc).lower():
156
+ _LOG.error(
157
+ "resume restore failed inside session_blob_decrypt; "
158
+ "refusing to fall through to fresh-start. Operator must "
159
+ "investigate the snapshot blob.",
160
+ )
161
+ raise
162
+ _LOG.exception(
163
+ "resume restore failed; falling back to fresh-start path "
164
+ "(Mongo blob preserved for inspection)",
165
+ )
166
+ await host.remove_file(opencode_db)
167
+ resuming = False
168
+ preserved_session_id = None
167
169
 
168
170
  async def _opencode_body(host: Host, hook_ctx: HookContext) -> None:
169
171
  """Opencode-specific body that runs inside the protocol driver.
@@ -368,6 +370,7 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
368
370
  await run_log_protocol_session(
369
371
  host, ctx,
370
372
  body=_opencode_body,
373
+ prepare=_prepare,
371
374
  on_deliverable=config.on_deliverable,
372
375
  after_execute=config.after_execute,
373
376
  protocol=protocol,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-opencode
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Run opencode web as an optio task; local subprocess or remote via SSH.
5
5
  Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
6
  License-Expression: Apache-2.0
@@ -22,9 +22,27 @@ def local_host(tmp_workdir):
22
22
  async def test_setup_workdir_creates_workdir(local_host):
23
23
  await local_host.setup_workdir()
24
24
  assert os.path.isdir(local_host.workdir)
25
- # As of the optio-host split, setup_workdir mkdirs the workdir only.
26
- # The protocol-specific deliverables/ + optio.log are owned by the
27
- # protocol session driver in optio_agents.protocol.session.
25
+ # setup_workdir destructively (re)creates the workdir (see the clean-start
26
+ # test below). The protocol-specific deliverables/ + optio.log are owned by
27
+ # the protocol session driver in optio_agents.protocol.session.
28
+
29
+
30
+ async def test_setup_workdir_wipes_stale_workdir_but_keeps_taskdir(local_host):
31
+ # Clean-start invariant: a destructive wipe of the workdir on every setup, so
32
+ # leftovers from a prior run (e.g. a force-cancel that skipped teardown) never
33
+ # leak into a fresh run. taskdir-side state (opencode.db / .env) lives OUTSIDE
34
+ # the workdir and must survive.
35
+ await local_host.setup_workdir()
36
+ with open(os.path.join(local_host.workdir, "stale.txt"), "w") as fh:
37
+ fh.write("leftover from a previous run")
38
+ taskdir_db = os.path.join(local_host.taskdir, "opencode.db")
39
+ with open(taskdir_db, "w") as fh:
40
+ fh.write("session transcript")
41
+
42
+ await local_host.setup_workdir()
43
+
44
+ assert not os.path.exists(os.path.join(local_host.workdir, "stale.txt"))
45
+ assert os.path.exists(taskdir_db)
28
46
 
29
47
 
30
48
  @pytest.mark.asyncio
@@ -240,3 +240,19 @@ async def test_remote_setup_workdir_sets_taskdir_and_workdir_mode_0o700(remote_h
240
240
  res_w = await remote_host._conn.run(f"stat -c %a {qw}", check=True)
241
241
  assert res_t.stdout.strip() == "700", f"taskdir mode is {res_t.stdout!r}"
242
242
  assert res_w.stdout.strip() == "700", f"workdir mode is {res_w.stdout!r}"
243
+
244
+
245
+ @pytest.mark.asyncio
246
+ async def test_remote_setup_workdir_wipes_stale_workdir_but_keeps_taskdir(remote_host: RemoteHost):
247
+ """Clean-start invariant asserted over SSH (mirrors the local test)."""
248
+ await remote_host.setup_workdir()
249
+ qt = shlex.quote(remote_host.taskdir)
250
+ qw = shlex.quote(remote_host.workdir)
251
+ await remote_host._conn.run(
252
+ f"touch {qw}/stale.txt {qt}/opencode.db", check=True,
253
+ )
254
+ await remote_host.setup_workdir()
255
+ stale = await remote_host._conn.run(f"test -e {qw}/stale.txt; echo $?")
256
+ keep = await remote_host._conn.run(f"test -e {qt}/opencode.db; echo $?")
257
+ assert stale.stdout.strip() == "1", "workdir was not wiped"
258
+ assert keep.stdout.strip() == "0", "taskdir state was lost"
File without changes
File without changes