Course: Master Course — Harness Engineering Module: 1 — The Execution Loop Duration: 90–120 minutes (substantial — this is the architectural-comparison lab) Environment: GitHub Codespace with n8n (Docker) + Node.js 22+. An OpenAI API key for the model calls (optional — the architectural comparison is visible even with mock responses).
By the end of this lab you will have:
# n8n (Docker, matching the Module 0.1 lab)
docker run -d --name n8n -p 5678:5678 -v n8n_data:/home/node/.n8n n8nio/n8n:latest
# OR if no Docker: follow the course's local Node 22 install path (see _shared/n8n/)
# Node for the graph-based phase
node --version # need 18+
Task definition (used in all three architectures):
Read
/tmp/NOTES.txt, summarize it in one sentence, write the summary to/tmp/SUMMARY.txt.
Deliberately simple, so the architectural difference is the variable, not the task complexity.
02-diagrams.md Diagram 6 into n8n./tmp/NOTES.txt with a few lines of content (in the n8n container: docker exec n8n sh -c 'echo "..." > /tmp/NOTES.txt')./tmp/NOTES.txt unreadable (chmod 000). Re-run.read_file? How many times before stop (max-iter)? This is ReAct's error compounding — a wrong action at turn 1 propagates.02-diagrams.md Diagram 7./tmp/NOTES.txt.read_file path for a wrong path.This is the Plan-then-Execute brittleness, felt.
Implement the same task as an explicit state machine.
// graph-loop.ts — minimal LangGraph-style state machine (no framework needed)
type State = { notesContent: string | null; summary: string | null; done: boolean };
async function planNode(s: State): Promise<Partial<State>> {
return {}; // graph is static; planning is the structure itself
}
async function readNode(s: State): Promise<Partial<State>> {
const content = await readFileSync("/tmp/NOTES.txt", "utf-8");
return { notesContent: content };
}
async function summarizeNode(s: State): Promise<Partial<State>> {
const summary = await model.complete([
{ role: "system", content: "Summarize in one sentence." },
{ role: "user", content: s.notesContent! }
]);
return { summary: summary.content };
}
async function writeNode(s: State): Promise<Partial<State>> {
await writeFileSync("/tmp/SUMMARY.txt", s.summary!);
return { done: true };
}
// The graph is explicit: read → summarize → write → done. No model decisions about flow.
const graph = [planNode, readNode, summarizeNode, writeNode];
let state: State = { notesContent: null, summary: null, done: false };
for (const node of graph) {
const patch = await node(state);
state = { ...state, ...patch };
emit({ turn_number: graph.indexOf(node), node: node.name, ...patch });
}
Run it. Observe: the flow is deterministic. No model call decides what to do next — the graph encodes it.
Now modify the task: "Read /tmp/NOTES.txt, and IF it mentions 'meeting', also read /tmp/CALENDAR.txt, then summarize." Your static graph cannot handle the conditional. You must either:
add_conditional_edges does — more code), ORThis is the rigidity cost. The graph encodes yesterday's task; tomorrow's task requires rewriting the graph.
Forward reference. The full 6-layer observability stack (structured logs, OpenTelemetry traces, token accounting, replay, metrics, session diffing) is taught in Module 10. Here you implement only the foundational layer — the per-turn structured record. This is intentional: you need to see why observability matters by building a loop without it (Phases 1–3) before learning the full system.
Add the 8-field per-turn payload to ONE of your three implementations (the ReAct one is most illustrative).
function emitTurn(traceId: string, turn: number, fields: Partial<TurnPayload>) {
console.log(JSON.stringify({
trace_id: traceId,
turn_number: turn,
timestamp: new Date().toISOString(),
...fields
}));
}
// In the ReAct loop, after each model call and each tool call:
emitTurn(traceId, turn++, {
tool_name: response.toolUse?.name ?? null,
input_hash: hash(response.toolUse?.input),
output_hash: hash(result),
latency_ms: Date.now() - start,
token_delta: response.usage.total_tokens,
stop_reason: response.stopReason
});
Run the instrumented ReAct loop and confirm the JSONL output. Verify:
stop_reason: "end_turn" appears on the final turn.token_delta is non-zero on model-call turns.The defining test for the dumb-loop philosophy. If you can swap models:
gpt-4o-mini).gpt-4o).For the graph-based implementation, the future-proof test reveals the rigidity: swapping the model does not change the flow (the graph is static). The dumb-loop architecture is the one that benefits most from model upgrades.
Record: turn count and summary quality for each model, in each architecture. The architecture where the model swap produces the biggest improvement is the most future-proof.
Submit module-1-lab-report.md:
chmod 000, the model retries read_file until max-iter (8).stop_reason shows end_turn on the last turn; token_delta > 0 on model-call turns.input_hash and output_hash match across 3 consecutive turns, halt with a "stuck" stop reason. (Direct tie to Module 7.2.)# Lab Specification — Module 1: The Execution Loop
**Course**: Master Course — Harness Engineering
**Module**: 1 — The Execution Loop
**Duration**: 90–120 minutes (substantial — this is the architectural-comparison lab)
**Environment**: GitHub Codespace with n8n (Docker) + Node.js 22+. An OpenAI API key for the model calls (optional — the architectural comparison is visible even with mock responses).
---
## Learning objectives
By the end of this lab you will have:
1. **Implemented the same task in three architectures** — ReAct and Plan-then-Execute (in n8n) and Graph-based (in TypeScript). Felt the architectural difference on one task.
2. **Provoked the failure mode unique to each** — ReAct error compounding, Plan-then-Execute brittleness, Graph rigidity.
3. **Instrumented the loop** with the 8-field per-turn observability payload.
4. **Run the future-proof test** — observed how each architecture responds to a model swap.
5. **Written an Architect's Verdict** on loop architecture, in the canonical template.
---
## Phase 0 — Environment setup (10 min)
```bash
# n8n (Docker, matching the Module 0.1 lab)
docker run -d --name n8n -p 5678:5678 -v n8n_data:/home/node/.n8n n8nio/n8n:latest
# OR if no Docker: follow the course's local Node 22 install path (see _shared/n8n/)
# Node for the graph-based phase
node --version # need 18+
```
**Task definition (used in all three architectures)**:
> Read `/tmp/NOTES.txt`, summarize it in one sentence, write the summary to `/tmp/SUMMARY.txt`.
Deliberately simple, so the architectural difference is the variable, not the task complexity.
---
## Phase 1 — ReAct in n8n (20 min)
1. Import the **ReAct workflow JSON** from `02-diagrams.md` Diagram 6 into n8n.
2. Create `/tmp/NOTES.txt` with a few lines of content (in the n8n container: `docker exec n8n sh -c 'echo "..." > /tmp/NOTES.txt'`).
3. Wire an OpenAI credential into the Call Model node.
4. Execute. Observe: how many turns does the loop run? (Expected: 2 — one to read, one to summarize+write or to confirm done.)
5. **Record the turn count and the tool sequence.**
### Failure-mode 1: error compounding
6. Make `/tmp/NOTES.txt` unreadable (`chmod 000`). Re-run.
7. Observe: does the model retry `read_file`? How many times before stop (max-iter)? This is ReAct's error compounding — a wrong action at turn 1 propagates.
---
## Phase 2 — Plan-then-Execute in n8n (20 min)
1. Import the **Plan-then-Execute workflow JSON** from `02-diagrams.md` Diagram 7.
2. Same `/tmp/NOTES.txt`.
3. Execute. Observe: the model plans ALL steps first, then executes them. **Record whether the plan is correct and how many steps it produces.**
### Failure-mode 2: plan brittleness
4. After the Plan node runs but before execution, manually edit the plan (in n8n's execution viewer, or by modifying the input to Step 1) to swap the `read_file` path for a wrong path.
5. Let execution continue. Observe: Step 2 (summarize) receives empty/garbage input and produces a garbage summary. Step 3 (write) writes the garbage. **No recovery.** The line cannot course-correct.
This is the Plan-then-Execute brittleness, felt.
---
## Phase 3 — Graph-based in TypeScript (25 min)
Implement the same task as an explicit state machine.
```typescript
// graph-loop.ts — minimal LangGraph-style state machine (no framework needed)
type State = { notesContent: string | null; summary: string | null; done: boolean };
async function planNode(s: State): Promise<Partial<State>> {
return {}; // graph is static; planning is the structure itself
}
async function readNode(s: State): Promise<Partial<State>> {
const content = await readFileSync("/tmp/NOTES.txt", "utf-8");
return { notesContent: content };
}
async function summarizeNode(s: State): Promise<Partial<State>> {
const summary = await model.complete([
{ role: "system", content: "Summarize in one sentence." },
{ role: "user", content: s.notesContent! }
]);
return { summary: summary.content };
}
async function writeNode(s: State): Promise<Partial<State>> {
await writeFileSync("/tmp/SUMMARY.txt", s.summary!);
return { done: true };
}
// The graph is explicit: read → summarize → write → done. No model decisions about flow.
const graph = [planNode, readNode, summarizeNode, writeNode];
let state: State = { notesContent: null, summary: null, done: false };
for (const node of graph) {
const patch = await node(state);
state = { ...state, ...patch };
emit({ turn_number: graph.indexOf(node), node: node.name, ...patch });
}
```
Run it. **Observe: the flow is deterministic. No model call decides what to do next — the graph encodes it.**
### Failure-mode 3: graph rigidity
Now modify the task: "Read `/tmp/NOTES.txt`, and IF it mentions 'meeting', also read `/tmp/CALENDAR.txt`, then summarize." Your static graph cannot handle the conditional. You must either:
- Add a conditional edge (which is what LangGraph's `add_conditional_edges` does — more code), OR
- Let the model decide (which abandons the graph).
This is the rigidity cost. The graph encodes yesterday's task; tomorrow's task requires rewriting the graph.
---
## Phase 4 — Instrument with the observability payload (15 min)
> **Forward reference.** The full 6-layer observability stack (structured logs, OpenTelemetry traces, token accounting, replay, metrics, session diffing) is taught in Module 10. Here you implement only the foundational layer — the per-turn structured record. This is intentional: you need to see *why* observability matters by building a loop without it (Phases 1–3) before learning the full system.
Add the 8-field per-turn payload to ONE of your three implementations (the ReAct one is most illustrative).
```typescript
function emitTurn(traceId: string, turn: number, fields: Partial<TurnPayload>) {
console.log(JSON.stringify({
trace_id: traceId,
turn_number: turn,
timestamp: new Date().toISOString(),
...fields
}));
}
// In the ReAct loop, after each model call and each tool call:
emitTurn(traceId, turn++, {
tool_name: response.toolUse?.name ?? null,
input_hash: hash(response.toolUse?.input),
output_hash: hash(result),
latency_ms: Date.now() - start,
token_delta: response.usage.total_tokens,
stop_reason: response.stopReason
});
```
Run the instrumented ReAct loop and confirm the JSONL output. Verify:
- Every turn emits exactly one record.
- `stop_reason: "end_turn"` appears on the final turn.
- `token_delta` is non-zero on model-call turns.
---
## Phase 5 — The future-proof test (10 min)
The defining test for the dumb-loop philosophy. If you can swap models:
1. Run your ReAct loop with a small/weak model (e.g. `gpt-4o-mini`).
2. Swap to a stronger model (e.g. `gpt-4o`).
3. Does the same loop perform better — fewer turns, better summary, correct tool use?
For the **graph-based** implementation, the future-proof test reveals the rigidity: swapping the model does not change the flow (the graph is static). The dumb-loop architecture is the one that benefits most from model upgrades.
**Record**: turn count and summary quality for each model, in each architecture. The architecture where the model swap produces the biggest improvement is the most future-proof.
---
## Deliverables
Submit `module-1-lab-report.md`:
- [ ] Phase 1: ReAct turn count + tool sequence; error-compounding observation
- [ ] Phase 2: Plan-then-Execute plan output + brittleness observation
- [ ] Phase 3: Graph-based code + rigidity observation (the conditional task problem)
- [ ] Phase 4: 5+ lines of the JSONL observability output; confirmation all 8 fields present
- [ ] Phase 5: turn-count + quality comparison across two models, per architecture
- [ ] **Architect's Verdict** (3 sentences) on which architecture you'd choose for a coding-assistant use case, with the tradeoff named
---
## Solution key
- **Phase 1**: a correct ReAct run produces ~2 turns for the simple task. Error compounding: with `chmod 000`, the model retries `read_file` until max-iter (8).
- **Phase 2**: a correct Plan-then-Execute produces a 3-step plan (read, summarize, write). Brittleness: editing the plan's read path propagates garbage to summary and write with no recovery.
- **Phase 3**: the graph-based implementation is deterministic — same steps every run. Rigidity: the conditional task ("if meeting, also read calendar") cannot be handled without adding a conditional edge or abandoning the graph.
- **Phase 4**: JSONL output has all 8 fields per turn; `stop_reason` shows `end_turn` on the last turn; `token_delta` > 0 on model-call turns.
- **Phase 5**: ReAct/dumb-loop architectures show the largest improvement from model swap (fewer turns, better quality). Graph-based shows the least (flow is static regardless of model).
- **Verdict**: a correct verdict names the architecture, the tradeoff, and the use-case fit. For a coding assistant: dumb-loop or ReAct (unpredictable tasks, future-proof). For a CI pipeline: Plan-then-Execute.
---
## Stretch goals
1. **Implement the conversation-driven variant**: two agents (a reader and a summarizer) that converse via a shared message list, with no explicit graph. Observe the non-determinism — run it 5 times, count how many turns each run takes.
2. **Add a stuck-loop detector**: monitor the observability output; if `input_hash` and `output_hash` match across 3 consecutive turns, halt with a "stuck" stop reason. (Direct tie to Module 7.2.)
3. **Port one architecture to Python** (LangGraph) and compare the developer experience. The TS implementation above is framework-free; LangGraph provides the graph primitives. Which is more legible? Which fights the model more?