diff --git a/README.org b/README.org index 08f8edd..40bc453 100644 --- a/README.org +++ b/README.org @@ -741,7 +741,9 @@ hand (an uncommitted tracked change there makes =updateInstead= refuse every publish from the clone; the prepare step warns loudly if it finds one). Ignored files (=.venv=, =node_modules=) are rebuilt each job; committed work is durable instantly, dirty work to within -=wip_snapshot_minutes=. +=wip_snapshot_minutes=. The agent is told all of this in a +=WORKSPACE (local-disk tiering)= block of its prelude, including the +command that shows the last snapshot. Earlier releases put the /whole/ mirror at =/local/mirrors= on one node. That layout is retired: a session that still records it gets a diff --git a/docs/local-disk-tiering.org b/docs/local-disk-tiering.org index 6074867..1f245a8 100644 --- a/docs/local-disk-tiering.org +++ b/docs/local-disk-tiering.org @@ -8,7 +8,9 @@ Design note. Written on a =carleton-htc= slice (job 38661192, n0036.savio4) against =main= at =80581c8=. Status: step (1) landed (shared timer + snapshotter, PR #9); step (2) landed for confined targets (PR #10) and for =salloc= targets, retiring the all-on-=/local= -layout (=sucoder/local_tier.py=, =slurm.local_disk= / =--local-disk=). +layout (=sucoder/local_tier.py=, =slurm.local_disk= / =--local-disk=); +step (3) landed: the prelude carries a =WORKSPACE (local-disk tiering)= +block whenever tiering is on (=MirrorManager._workspace_block=). * Punchline @@ -187,8 +189,10 @@ state the durability tiers: intervened. - Ignored :: never durable; rebuilt from scratch each job. -The prelude can print the last snapshot time so the agent knows the -loop is alive. +The prelude is rendered before launch, so it cannot print the last +snapshot time; it gives the one-line =git log= that answers it +instead, plus the clone and mirror paths, the mailbox rule, and the +warn-file path. * Config and docs diff --git a/docs/persistent-presence.org b/docs/persistent-presence.org index dd0f358..303958f 100644 --- a/docs/persistent-presence.org +++ b/docs/persistent-presence.org @@ -466,9 +466,11 @@ and makes re-grab fast when turnover does happen. Recommended: =time: "3-00:00:00"= with =max_idle= as the real courtesy guard. ** Handoff / rehydration -- Before turnover the agent writes a structured note to a fixed NFS - path, e.g. =~/mirrors//.sucoder/handoff.org=: current task, - branch, last commit SHA, open questions, next action. +- Before turnover the agent writes a structured note to + =.sucoder/handoff.org= in its working tree and commits it: current + task, branch, last commit SHA, open questions, next action. Under + local-disk tiering the commit is what carries it to the shared mirror + (writing into =~/mirrors/= by hand would block every publish). - On relaunch, =system_prompt_extra= instructs the agent to *first* read the handoff + =git log -5= + =git status=, summarize the resumed state back to the human, then continue. This turns a cold diff --git a/sucoder/mirror.py b/sucoder/mirror.py index 16acb21..ccf8c9f 100644 --- a/sucoder/mirror.py +++ b/sucoder/mirror.py @@ -5254,6 +5254,13 @@ def _compose_context_prelude(self, ctx: MirrorContext) -> str: if target_block: blocks.append(target_block) + # Under local-disk tiering the agent's cwd is a node-local clone, + # not the mirror the prompts talk about; say so, with the facts + # it needs (where commits go, what survives, where to look). + workspace_block = self._workspace_block(ctx) + if workspace_block: + blocks.append(workspace_block) + agent_doc = self._agent_doc_block(ctx) if agent_doc: blocks.append(agent_doc) @@ -5316,6 +5323,64 @@ def _target_prompt_block(self, ctx: MirrorContext) -> Optional[str]: header = f"TARGET PROMPT ({self._collapse_home(prompt_path)})" return f"{header}\n{content}" + def _workspace_block(self, ctx: MirrorContext) -> Optional[str]: + """Describe the local-disk tiering layout to the agent, when it applies. + + Rendered on the laptop before launch, so it cannot carry runtime + state (last snapshot time); it gives the command that answers that + instead. For a confined launch the job id is not known yet and the + path is spelled with ``$SUCODER_LOCAL_ROOT``, which the batch body + exports before starting tmux (``local_tier.cache_exports_sh``). + """ + if not ctx.is_remote: + return None + local_disk_root = getattr(self.executor, "local_disk_root", None) or None + if not local_disk_root: + return None + token = _sanitize_session_token(ctx.settings.name) + try: + mirror_path = self._resolve_remote_path(ctx) + except Exception as exc: # noqa: BLE001 - informational block, never fatal + self.logger.debug("Workspace block: could not resolve mirror path: %s", exc) + mirror_path = ctx.remote_mirror_path or "~/mirrors/" + job_id = None if ctx.confined else getattr(self.executor, "slurm_job_id", None) + root = local_disk_root.rstrip("/") or "/" + if job_id: + work = work_path(local_disk_root, token, int(job_id)) + local_root = f"{root}/job{job_id}" + else: + # Confined: the id is assigned by sbatch after this renders. + # $SUCODER_LOCAL_ROOT (= /job) is exported by the + # batch body, so it expands in the agent's shell and tools. + work = f"$SUCODER_LOCAL_ROOT/mirrors/{token}" + local_root = f"{root}/job" + slurm = ctx.settings.remote.slurm if ctx.settings.remote else None + minutes = slurm.wip_snapshot_minutes if slurm else 10 + cadence = ( + f"every {minutes} minutes and at each deadline warning" + if minutes else "at each deadline warning only" + ) + lines = [ + "WORKSPACE (local-disk tiering)", + "You are working in a node-local clone, not in the shared mirror the prompts above describe.", + f"- Working clone (your cwd): {work}" + + ("" if job_id else " ($SUCODER_LOCAL_ROOT is exported in your environment; `pwd` shows it resolved)"), + f"- Shared mirror (origin; durable; the human's push/pull target): {mirror_path}", + " Never edit its working tree by hand: an uncommitted tracked change there blocks every publish from this clone.", + "- Every commit is published to the shared mirror by a post-commit hook the moment it exists; the commit output", + " shows 'SUCODER: published'. A 'REJECTED' line means the human pushed first: `git pull --ff-only`, then commit", + " again. Never force-push to origin.", + f"- Uncommitted work (tracked or untracked, not ignored) is snapshotted to refs/sucoder/wip/{token} on the shared", + f" mirror {cadence}; the next launch restores it if no commit has landed since.", + f" Last snapshot: git -C {mirror_path} log -1 --format='%ci %s' refs/sucoder/wip/{token}", + f"- Ignored files (.venv, node_modules, caches) are never durable. Caches and $TMPDIR live under {local_root}", + " ($SUCODER_LOCAL_ROOT) and are rebuilt each job.", + f"- Deadline warnings: $HOME/.cache/sucoder/slurm-deadline-{token}.warn (30/15/5 minutes before the job's --time).", + "- Handoff notes go to .sucoder/handoff.org in this clone, committed.", + "- A job you dispatch to another node cannot see this clone: give it the shared mirror or a branch you have committed.", + ] + return "\n".join(lines) + def _agent_doc_block(self, ctx: MirrorContext) -> Optional[str]: """Inject ``AGENT.md`` / ``AGENT.org`` for non-Claude agents. diff --git a/tests/test_mirror.py b/tests/test_mirror.py index 6ae1c40..09806f1 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -1034,6 +1034,104 @@ def fake_run_agent(args, **kwargs): assert not calls[-1]["args"][-1].startswith("export ") +def _staged_prelude(calls): + """The prelude text externalized over SSH stdin for a remote launch.""" + for c in calls: + a = c["args"] + if a[0] == "sh" and "prelude-" in a[2] and c["kwargs"].get("input"): + return c["kwargs"]["input"] + raise AssertionError("no prelude was staged") + + +def test_prelude_workspace_block_unconfined_local_tier(tmp_path, monkeypatch): + """With tiering on, the prelude tells the agent where it works, where + commits go, what survives, and how to check the last snapshot; the + unconfined path knows the job id, so paths are literal.""" + manager, ctx = _remote_launch_manager(tmp_path, monkeypatch, local_disk_root="/local") + manager.config.system_prompt = tmp_path / "sys.org" + manager.config.system_prompt.write_text("SYS\n") + calls = [] + + def fake_run_agent(args, **kwargs): + calls.append({"args": list(args), "kwargs": kwargs}) + return CommandResult(requested_args=list(args), executed_args=list(args), + stdout="", stderr="", returncode=0) + + monkeypatch.setattr(manager.executor, "run_agent", fake_run_agent) + manager.launch_agent(ctx, sync=False) + + prelude = _staged_prelude(calls) + assert "WORKSPACE (local-disk tiering)" in prelude + assert "Working clone (your cwd): /local/job1234567/mirrors/sample" in prelude + assert "Shared mirror (origin; durable; the human's push/pull target): /global/home/users/coder/mirrors/sample" in prelude + assert "refs/sucoder/wip/sample" in prelude + assert "git -C /global/home/users/coder/mirrors/sample log -1 --format='%ci %s' refs/sucoder/wip/sample" in prelude + assert "slurm-deadline-sample.warn" in prelude + assert "every 10 minutes and at each deadline warning" in prelude + assert ".sucoder/handoff.org in this clone" in prelude + # Ordering: after the system prompt, before the skill catalog. + assert prelude.index("SYSTEM PROMPT") < prelude.index("WORKSPACE (local-disk tiering)") + + +def test_prelude_workspace_block_absent_without_local_tier(tmp_path, monkeypatch): + manager, ctx = _remote_launch_manager(tmp_path, monkeypatch, local_disk_root=None) + manager.config.system_prompt = tmp_path / "sys.org" + manager.config.system_prompt.write_text("SYS\n") + calls = [] + + def fake_run_agent(args, **kwargs): + calls.append({"args": list(args), "kwargs": kwargs}) + return CommandResult(requested_args=list(args), executed_args=list(args), + stdout="", stderr="", returncode=0) + + monkeypatch.setattr(manager.executor, "run_agent", fake_run_agent) + manager.launch_agent(ctx, sync=False) + assert "WORKSPACE (local-disk tiering)" not in _staged_prelude(calls) + + +def test_prelude_workspace_block_confined_uses_runtime_job_id(tmp_path, monkeypatch): + """A confined launch renders the prelude before sbatch assigns the job + id, so the clone path is spelled with $SLURM_JOB_ID.""" + manager, ctx = _confined_manager(tmp_path, monkeypatch) + manager.executor.local_disk_root = "/local" + ctx.settings.remote.slurm.wip_snapshot_minutes = 0 + calls = [] + manager.executor.run_agent = _confined_responder(calls, sbatch_out="9") + + manager._launch_confined( + ctx, ["claude"], remote_prelude_text=manager._compose_context_prelude(ctx), + prelude_sentinel="__X__", env=None, detached=True, + ) + prelude = next(c["input"] for c in calls if c["args"][0] == "sh" and "prelude-" in c["args"][2]) + assert "Working clone (your cwd): $SUCODER_LOCAL_ROOT/mirrors/sample" in prelude + assert "$SUCODER_LOCAL_ROOT is exported in your environment" in prelude + assert "at each deadline warning only" in prelude + + +def test_launch_agent_confined_local_tier_prelude_reaches_batch(tmp_path, monkeypatch): + """End to end through launch_agent: a confined target with tiering gets + the WORKSPACE block (confined spelling) in the prelude that the batch + job's agent reads, even though the executor has no job id yet.""" + manager, ctx = _confined_manager(tmp_path, monkeypatch) + manager.executor.local_disk_root = "/local" + manager.executor.slurm_job_id = None + manager.config.system_prompt = tmp_path / "sys.org" + manager.config.system_prompt.write_text("SYS\n") + monkeypatch.setattr( + MirrorManager, "_default_skills_catalog_path", lambda self: None, + ) + calls = [] + manager.executor.run_agent = _confined_responder(calls, sbatch_out="9") + + manager.launch_agent(ctx, sync=False, detached=True) + + assert ctx.confined is True + prelude = next(c["input"] for c in calls if c["args"][0] == "sh" and "prelude-" in c["args"][2]) + assert "WORKSPACE (local-disk tiering)" in prelude + assert "Working clone (your cwd): $SUCODER_LOCAL_ROOT/mirrors/sample" in prelude + assert "/local/job" not in prelude.split("WORKSPACE (local-disk tiering)")[1].split("Shared mirror")[0] + + def test_build_remote_agent_cmd_str_joins_and_appends_exec_bash(tmp_path: Path) -> None: """The extracted helper joins the command and appends ``; exec bash -l``.