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)

# 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

  1. Make /tmp/NOTES.txt unreadable (chmod 000). Re-run.
  2. 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

  1. 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.
  2. 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.

// 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:

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).

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:


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:


Solution key


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?
# 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?