- The last piece of Domain 1
- A session is just a saved conversation
- Resuming: continuing under a name
- Forking: two branches, one shared baseline
- When resuming isn’t enough
- Critical design decisions
- Consequences
Lessons Learned While Preparing for the Claude Architect Certification
The last piece of Domain 1
Every part so far has run inside one process, start to finish: launch the script, the loop does its work, the script ends, nothing survives it. Task 1.7 is about the gap that leaves — what happens when the work spans more than one sitting. You investigate something today, close your laptop, and come back to it tomorrow. Or you reach a point where two different next steps both seem worth trying, and you don’t want either one to interfere with the other.
The example this time is one small file, cache.py, investigated the way you’d actually investigate something real: not in a single conversation, but across several. A session gets started, closed, picked back up, and split into two directions — and once, the file itself changes in between.
A session is just a saved conversation
Nothing exotic sits behind --resume <name> or fork_session. Both are built on one idea: a session is the messages list the loop has been building up all along, saved somewhere under a name.
def save_session(name: str, messages: list[dict[str, Any]]) -> None:
def as_plain_dict(block: Any) -> Any:
if hasattr(block, "model_dump"):
return block.model_dump()
raise TypeError(f"Not JSON-serializable: {block!r}")
(SESSION_DIR / f"{name}.json").write_text(json.dumps(messages, default=as_plain_dict))
def load_session(name: str) -> list[dict[str, Any]] | None:
path = SESSION_DIR / f"{name}.json"
return json.loads(path.read_text()) if path.exists() else None
The one wrinkle is that an assistant turn’s content isn’t plain data — it’s a list of SDK objects (a TextBlock, a ToolUseBlock), and json.dumps doesn’t know what to do with those on its own. default=as_plain_dict is the fix: whenever the encoder hits something it can’t serialize directly, it calls .model_dump() on it first. Everything else in messages — the user turns, the tool results — is already a plain dict and never touches that path.
Because sessions now need to survive as data instead of live SDK objects, the loop itself changes shape slightly from the earlier parts. It takes a starting messages list instead of building one from a single opening message, and it hands the full updated list back at the end, so the caller has something to save:
def run_agentic_loop(
messages: list[dict[str, Any]],
system: str,
tools: list[dict[str, Any]],
tool_impls: dict[str, Callable[..., str]],
max_iterations: int = DEFAULT_MAX_ITERATIONS,
) -> tuple[str, list[dict[str, Any]]]:
...
Everything downstream of this is just two ways of using that list: resume one by name, or copy one under a new name.
Resuming: continuing under a name
There’s deliberately no separate “start a new session” function here. resume_session handles both cases, because --resume <name> in the real CLI does too — a name either already has a saved conversation behind it, or it doesn’t, and the same command works either way:
def resume_session(name: str, user_message: str, system: str, tools, tool_impls) -> str:
messages = load_session(name) or []
messages.append({"role": "user", "content": user_message})
answer, messages = run_agentic_loop(messages, system, tools, tool_impls)
save_session(name, messages)
return answer
Starting a session named "cache-investigation" and asking it to read cache.py, then resuming that same name with “is this safe to call from multiple threads at once?”, the second call never touches read_cache_file again — the file’s contents are already sitting in the saved history from the first call, and the follow-up answers straight from that. That’s the entire value of --resume <name>: the next work session picks up exactly where the last one left off, without repeating the investigation.
You can find the full file on GitHub. A PHP port is available too, built the same way as the earlier parts — and slightly simpler on this one point, since every SDK response object in the PHP library already implements JsonSerializable, so a plain json_encode($messages) handles the conversion with no extra step at all.
Forking: two branches, one shared baseline
fork_session is even smaller than resuming — it doesn’t run the loop at all, it just copies a saved session under a new name:
def fork_session(source_name: str, new_name: str) -> None:
messages = load_session(source_name)
if messages is None:
raise ValueError(f"No saved session named {source_name!r}")
save_session(new_name, messages) # new_name now has its own copy to diverge from
Forking "cache-investigation" into "cache-investigation-thread-safety" and "cache-investigation-ttl" gives both names an identical copy of everything established so far — the same file, the same initial analysis. Resuming each one with a different question from there (one about thread safety, one about adding expiration) sends them in genuinely different directions, and neither resume touches the other’s saved file. That’s what makes it a fork instead of two independent investigations: both branches start from work that’s already been done once, instead of paying to redo it twice.
When resuming isn’t enough
Resuming assumes the world hasn’t changed since the session was saved. Sometimes it has — the fake cache.py in this example gets edited partway through, gaining a threading.Lock it didn’t have when the earlier sessions read it. Asking the resumed session for a recap after that edit is a real test of what “trusting a resumed session” actually costs:
[blind resume, no mention of the edit — outcome depends on the model]
Thread-safety verdict: ❌ Not thread-safe. A race condition exists between
the key check and the cache write...
[informed resume: told exactly what changed]
Yes, the updated cache.py is now thread-safe. The threading.Lock() ensures
that only one thread at a time can execute the critical section...
In this run, the blind resume answered from the stale tool result still sitting in its history — the file had already changed, but nothing told the session that, and nothing forced it to check again. That’s not a guaranteed outcome; a model asked a more pointed question, or just choosing to double-check, can and sometimes does re-read the file on its own. That inconsistency is exactly the problem: whether a blind resume gets it right is a gamble on what the model decides to do, not something the code guarantees either way.
The fix removes the gamble instead of hoping to win it. Telling the resumed session directly what changed —
resume_session(
"cache-investigation",
f"cache.py was just edited. Its new contents:\n\n{CACHE_FILE_V2}\n\n"
"Given this update, is it thread-safe now?",
SYSTEM_PROMPT, TOOLS, TOOL_IMPLS,
)
— means the answer no longer depends on whether the model thinks to re-check anything. The current state is already in the prompt.
For a single file, a short note like that is enough. When a lot has changed since the last session, re-explaining each change piece by piece stops being practical, and a saved session might also be dragging along a long history of tool calls that are no longer relevant to what’s being asked now. That’s when it’s more reliable to stop resuming and start over instead, seeding the fresh session with a short, structured summary of what’s actually still true:
start_fresh_with_summary(
"Prior finding: cache.py used to have no locking around its dict access. "
"It has since been edited to wrap that access in a threading.Lock.",
"Is the cache thread-safe now?",
SYSTEM_PROMPT, TOOLS, TOOL_IMPLS,
)
No file on disk, no accumulated tool results from three prior turns — just the one fact that’s still relevant, stated directly. In this run, the fresh-start answer went further than either resume: it confirmed the lock fixes the original race condition, but also noticed that expensive_lookup runs while the lock is held, which risks serializing every call and could deadlock if that function ever calls back into get. Nothing about starting fresh caused that extra insight, but nothing about a bloated resumed history was competing for the model’s attention either.
Critical design decisions
- A session is data, not a live object: saving one means converting SDK response objects into plain dicts first.
save_session’sdefault=as_plain_dictis where that conversion happens, once, instead of scattered through the rest of the code. - The loop hands its state back:
run_agentic_loopreturns the full updatedmessageslist because something outside it —resume_session— needs to persist it. A loop that only returns a final answer has nothing left to save. - One function for “start” and “continue”:
resume_sessiondoesn’t need a separate creation path.load_session(name) or []already says everything that distinction would: either there’s a conversation to build on, or there isn’t, and the rest of the function doesn’t care which. - Forking is a copy, not a merge:
fork_sessionjust duplicates a saved file under a new name. The branches diverge because they get resumed with different questions afterward, not because forking itself does anything clever. - Don’t trust a resumed session to notice staleness on its own: whether it re-checks a changed file is up to the model, not something the code can rely on. Saying what changed, in the resumed prompt itself, is what actually guarantees the answer reflects it.
- Fresh-with-summary scales better than fresh-with-full-history: a short, curated summary of what’s still true survives a lot of change better than a long resumed history full of tool results that no longer apply.
Consequences
- Treating SDK response objects as if they were already JSON ->
save_sessionfails the moment it hits the firstTextBlockorToolUseBlock, since neither is serializable without help. - A loop that never returns its own message history -> nothing outside it has anything to hand
save_session, so nothing can be resumed later no matter how well the loop itself works. - Assuming a blind resume will notice a changed file -> sometimes it does, sometimes it recaps last time’s now-outdated conclusion, and which one happens isn’t something the calling code controls.
- Resuming a long, stale history instead of starting fresh with a summary -> the model has to sort the parts that still matter from the parts that don’t, using tokens and attention that a short, curated summary wouldn’t have spent at all.
That closes out Domain 1. Every part built on the one before it — a loop that could call tools, a coordinator that could delegate, subagents that got exactly the context they needed, workflows that enforced their own rules instead of asking nicely, hooks that intercepted what the tools saw and did, two shapes of decomposition for two shapes of problem, and now, sessions that outlive the process that started them.