baa-conductor

git clone 

commit
d3b7b7b
parent
d0395f7
author
im_wower
date
2026-03-21 21:37:22 +0800 CST
Merge branch 'feat/T-005-worker-runner'
10 files changed,  +995, -58
A apps/worker-runner/src/contracts.ts
+125, -0
  1@@ -0,0 +1,125 @@
  2+import type {
  3+  LocalRunLogSession,
  4+  LocalRunLogSummary,
  5+  LocalRunPaths,
  6+  RunMetadata,
  7+  RunStateSnapshot,
  8+  WorkerLifecycleLogEntry
  9+} from "@baa-conductor/logging";
 10+
 11+export type WorkerKind = "codex" | "shell" | "git";
 12+export type StepKind = WorkerKind | "planner" | "review" | "finalize";
 13+export type StepExecutionOutcome = "prepared" | "completed" | "failed" | "blocked";
 14+export type CheckpointMode = "disabled" | "reserved_for_t006";
 15+export type StepArtifactKind = "log" | "metadata" | "state" | "directory" | "artifact";
 16+
 17+export interface StepExecutionRuntime {
 18+  repoRoot: string;
 19+  worktreePath: string;
 20+  runsRootDir?: string;
 21+  env?: Record<string, string>;
 22+}
 23+
 24+export interface StepCheckpointConfig {
 25+  mode: CheckpointMode;
 26+  resumeFromSeq?: number;
 27+  summaryHint?: string;
 28+}
 29+
 30+export interface StepExecutionRequest {
 31+  taskId: string;
 32+  stepId: string;
 33+  runId: string;
 34+  attempt: number;
 35+  stepName: string;
 36+  stepKind: StepKind;
 37+  workerKind: WorkerKind;
 38+  timeoutSec: number;
 39+  runtime: StepExecutionRuntime;
 40+  createdAt?: string;
 41+  promptSummary?: string;
 42+  command?: string;
 43+  checkpoint?: StepCheckpointConfig;
 44+}
 45+
 46+export interface StepCheckpointState {
 47+  mode: CheckpointMode;
 48+  lastCheckpointSeq: number;
 49+  nextCheckpointSeq: number;
 50+  resumeFromSeq?: number;
 51+  deferredFeatures: Array<"git_diff">;
 52+  note: string;
 53+}
 54+
 55+export interface StepArtifact {
 56+  name: string;
 57+  kind: StepArtifactKind;
 58+  path: string;
 59+  description: string;
 60+}
 61+
 62+export interface SuggestedFollowup {
 63+  stepName: string;
 64+  stepKind: StepKind;
 65+  reason: string;
 66+}
 67+
 68+export interface WorkerExecutionOutcome {
 69+  ok: boolean;
 70+  outcome: StepExecutionOutcome;
 71+  summary: string;
 72+  needsHuman?: boolean;
 73+  blocked?: boolean;
 74+  stdout?: string[];
 75+  stderr?: string[];
 76+  artifacts?: StepArtifact[];
 77+  suggestedFollowup?: SuggestedFollowup[];
 78+  exitCode?: number;
 79+}
 80+
 81+export interface WorkerExecutor {
 82+  execute(run: PreparedStepRun): Promise<WorkerExecutionOutcome>;
 83+}
 84+
 85+export interface PreparedStepRun {
 86+  request: StepExecutionRequest;
 87+  logPaths: LocalRunPaths;
 88+  logSession: LocalRunLogSession;
 89+  metadata: RunMetadata;
 90+  state: RunStateSnapshot;
 91+  checkpoint: StepCheckpointState;
 92+}
 93+
 94+export interface StepExecutionMetrics {
 95+  durationMs: number;
 96+  lifecycleEventCount: number;
 97+  stdoutChunkCount: number;
 98+  stderrChunkCount: number;
 99+  checkpointCount: number;
100+}
101+
102+export interface StepExecutionResult {
103+  ok: boolean;
104+  outcome: StepExecutionOutcome;
105+  summary: string;
106+  needsHuman: boolean;
107+  blocked: boolean;
108+  taskId: string;
109+  stepId: string;
110+  runId: string;
111+  attempt: number;
112+  workerKind: WorkerKind;
113+  startedAt: string;
114+  finishedAt: string;
115+  durationMs: number;
116+  metadata: RunMetadata;
117+  state: RunStateSnapshot;
118+  logPaths: LocalRunPaths;
119+  logSession: LocalRunLogSession;
120+  logSummary: LocalRunLogSummary;
121+  lifecycleEvents: WorkerLifecycleLogEntry[];
122+  checkpoint: StepCheckpointState;
123+  artifacts: StepArtifact[];
124+  suggestedFollowup: SuggestedFollowup[];
125+  metrics: StepExecutionMetrics;
126+}
M apps/worker-runner/src/index.ts
+2, -26
 1@@ -1,26 +1,2 @@
 2-export type WorkerKind = "codex" | "shell" | "git";
 3-
 4-export interface StepExecutionRequest {
 5-  taskId: string;
 6-  stepId: string;
 7-  stepName: string;
 8-  workerKind: WorkerKind;
 9-  logDir: string;
10-}
11-
12-export interface StepExecutionResult {
13-  ok: boolean;
14-  summary: string;
15-  checkpointCount: number;
16-  logsPath: string;
17-}
18-
19-export async function runStep(request: StepExecutionRequest): Promise<StepExecutionResult> {
20-  return {
21-    ok: false,
22-    summary: `Step ${request.stepId} 仅有骨架,尚未接入真实 ${request.workerKind} worker。`,
23-    checkpointCount: 0,
24-    logsPath: request.logDir
25-  };
26-}
27-
28+export * from "./contracts";
29+export * from "./runner";
A apps/worker-runner/src/runner.ts
+382, -0
  1@@ -0,0 +1,382 @@
  2+import {
  3+  appendStreamChunk,
  4+  createLocalRunLogSession,
  5+  createLocalRunPaths,
  6+  createRunMetadata,
  7+  createRunStateSnapshot,
  8+  recordLifecycleEvent,
  9+  summarizeLocalRunLogSession,
 10+  updateRunState,
 11+  type LogLevel,
 12+  type RunStatePatch,
 13+  type RunStatus,
 14+  type WorkerLifecycleEventType
 15+} from "@baa-conductor/logging";
 16+import type {
 17+  PreparedStepRun,
 18+  StepArtifact,
 19+  StepCheckpointConfig,
 20+  StepCheckpointState,
 21+  StepExecutionOutcome,
 22+  StepExecutionRequest,
 23+  StepExecutionResult,
 24+  WorkerExecutionOutcome,
 25+  WorkerExecutor
 26+} from "./contracts";
 27+
 28+const DEFERRED_CHECKPOINT_FEATURES: Array<"git_diff"> = ["git_diff"];
 29+
 30+function resolveCheckpointConfig(config?: StepCheckpointConfig): StepCheckpointConfig {
 31+  return config ?? {
 32+    mode: "reserved_for_t006"
 33+  };
 34+}
 35+
 36+function createCheckpointState(config: StepCheckpointConfig): StepCheckpointState {
 37+  const lastCheckpointSeq = config.resumeFromSeq ?? 0;
 38+
 39+  return {
 40+    mode: config.mode,
 41+    lastCheckpointSeq,
 42+    nextCheckpointSeq: lastCheckpointSeq + 1,
 43+    resumeFromSeq: config.resumeFromSeq,
 44+    deferredFeatures: [...DEFERRED_CHECKPOINT_FEATURES],
 45+    note:
 46+      config.mode === "reserved_for_t006"
 47+        ? "Checkpoint paths and sequence numbers are reserved for T-006. T-005 does not emit checkpoint content."
 48+        : "Checkpointing is disabled for this step request."
 49+  };
 50+}
 51+
 52+function synchronizeRunState(run: PreparedStepRun, patch: RunStatePatch): void {
 53+  run.state = updateRunState(run.state, {
 54+    ...patch,
 55+    lastEventSeq: run.logSession.nextSeq - 1,
 56+    checkpointSeq: run.checkpoint.lastCheckpointSeq
 57+  });
 58+}
 59+
 60+function createDefaultArtifacts(run: PreparedStepRun): StepArtifact[] {
 61+  return [
 62+    {
 63+      name: "meta.json",
 64+      kind: "metadata",
 65+      path: run.logPaths.metaPath,
 66+      description: "Immutable run metadata for this step attempt."
 67+    },
 68+    {
 69+      name: "state.json",
 70+      kind: "state",
 71+      path: run.logPaths.statePath,
 72+      description: "Mutable run state snapshot for progress tracking and recovery."
 73+    },
 74+    {
 75+      name: "worker.log",
 76+      kind: "log",
 77+      path: run.logPaths.workerLogPath,
 78+      description: "Lifecycle events rendered into the worker log stream."
 79+    },
 80+    {
 81+      name: "stdout.log",
 82+      kind: "log",
 83+      path: run.logPaths.stdoutLogPath,
 84+      description: "Raw stdout stream produced by the worker process."
 85+    },
 86+    {
 87+      name: "stderr.log",
 88+      kind: "log",
 89+      path: run.logPaths.stderrLogPath,
 90+      description: "Raw stderr stream produced by the worker process."
 91+    },
 92+    {
 93+      name: "checkpoints",
 94+      kind: "directory",
 95+      path: run.logPaths.checkpointsDir,
 96+      description: "Reserved checkpoint directory for future T-006 persistence."
 97+    },
 98+    {
 99+      name: "artifacts",
100+      kind: "directory",
101+      path: run.logPaths.artifactsDir,
102+      description: "Reserved artifact directory for worker outputs."
103+    }
104+  ];
105+}
106+
107+function mergeArtifacts(run: PreparedStepRun, extraArtifacts: StepArtifact[] = []): StepArtifact[] {
108+  const merged = new Map<string, StepArtifact>();
109+
110+  for (const artifact of createDefaultArtifacts(run)) {
111+    merged.set(artifact.path, artifact);
112+  }
113+
114+  for (const artifact of extraArtifacts) {
115+    merged.set(artifact.path, artifact);
116+  }
117+
118+  return [...merged.values()];
119+}
120+
121+function mapOutcomeToRunStatus(outcome: StepExecutionOutcome): RunStatus {
122+  switch (outcome) {
123+    case "prepared":
124+      return "prepared";
125+    case "completed":
126+      return "completed";
127+    case "failed":
128+      return "failed";
129+    case "blocked":
130+      return "blocked";
131+  }
132+}
133+
134+function mapOutcomeToTerminalEvent(outcome: StepExecutionOutcome): WorkerLifecycleEventType {
135+  switch (outcome) {
136+    case "prepared":
137+      return "step_prepared";
138+    case "completed":
139+      return "step_completed";
140+    case "failed":
141+      return "step_failed";
142+    case "blocked":
143+      return "step_blocked";
144+  }
145+}
146+
147+function mapOutcomeToLevel(execution: WorkerExecutionOutcome): LogLevel {
148+  if (execution.outcome === "failed") {
149+    return "error";
150+  }
151+
152+  if (execution.outcome === "blocked") {
153+    return "warn";
154+  }
155+
156+  return "info";
157+}
158+
159+function resolveExitCode(execution: WorkerExecutionOutcome): number {
160+  if (execution.exitCode !== undefined) {
161+    return execution.exitCode;
162+  }
163+
164+  return execution.ok ? 0 : 1;
165+}
166+
167+export function prepareStepRun(request: StepExecutionRequest): PreparedStepRun {
168+  const startedAt = request.createdAt ?? new Date().toISOString();
169+  const checkpointConfig = resolveCheckpointConfig(request.checkpoint);
170+  const checkpoint = createCheckpointState(checkpointConfig);
171+  const logPaths = createLocalRunPaths({
172+    repoRoot: request.runtime.repoRoot,
173+    taskId: request.taskId,
174+    runId: request.runId,
175+    runsRootDir: request.runtime.runsRootDir
176+  });
177+  const logSession = createLocalRunLogSession(logPaths, {
178+    taskId: request.taskId,
179+    stepId: request.stepId,
180+    runId: request.runId
181+  });
182+  const run: PreparedStepRun = {
183+    request,
184+    logPaths,
185+    logSession,
186+    metadata: createRunMetadata({
187+      taskId: request.taskId,
188+      stepId: request.stepId,
189+      stepName: request.stepName,
190+      runId: request.runId,
191+      workerKind: request.workerKind,
192+      attempt: request.attempt,
193+      repoRoot: request.runtime.repoRoot,
194+      worktreePath: request.runtime.worktreePath,
195+      createdAt: startedAt,
196+      checkpointMode: checkpoint.mode,
197+      promptSummary: request.promptSummary,
198+      command: request.command
199+    }),
200+    state: createRunStateSnapshot({
201+      attempt: request.attempt,
202+      startedAt,
203+      checkpointSeq: checkpoint.lastCheckpointSeq,
204+      summary: `Prepared local run layout for ${request.stepId}.`
205+    }),
206+    checkpoint
207+  };
208+
209+  recordLifecycleEvent(run.logSession, {
210+    type: "run_prepared",
211+    level: "info",
212+    createdAt: startedAt,
213+    message: `Prepared local run layout for ${request.stepId}.`,
214+    data: {
215+      stepName: request.stepName,
216+      stepKind: request.stepKind,
217+      workerKind: request.workerKind,
218+      attempt: request.attempt,
219+      timeoutSec: request.timeoutSec,
220+      runDir: run.logPaths.runDir,
221+      worktreePath: request.runtime.worktreePath
222+    }
223+  });
224+
225+  if (checkpoint.mode === "reserved_for_t006") {
226+    recordLifecycleEvent(run.logSession, {
227+      type: "checkpoint_slot_reserved",
228+      level: "info",
229+      message: `Reserved checkpoint sequence ${checkpoint.nextCheckpointSeq} for future T-006 integration.`,
230+      data: {
231+        checkpointsDir: run.logPaths.checkpointsDir,
232+        nextCheckpointSeq: checkpoint.nextCheckpointSeq,
233+        deferredFeatures: checkpoint.deferredFeatures
234+      }
235+    });
236+  }
237+
238+  synchronizeRunState(run, {
239+    updatedAt: startedAt
240+  });
241+
242+  return run;
243+}
244+
245+export function createPlaceholderWorkerExecutor(): WorkerExecutor {
246+  return {
247+    async execute(run: PreparedStepRun): Promise<WorkerExecutionOutcome> {
248+      return {
249+        ok: true,
250+        outcome: "prepared",
251+        summary: `Prepared local log streams and result envelope for ${run.request.stepId}; real ${run.request.workerKind} execution remains deferred.`,
252+        needsHuman: false,
253+        blocked: false,
254+        exitCode: 0
255+      };
256+    }
257+  };
258+}
259+
260+export async function runStep(
261+  request: StepExecutionRequest,
262+  executor: WorkerExecutor = createPlaceholderWorkerExecutor()
263+): Promise<StepExecutionResult> {
264+  const startedAt = request.createdAt ?? new Date().toISOString();
265+  const run = prepareStepRun({
266+    ...request,
267+    createdAt: startedAt
268+  });
269+  const executionStartedAt = new Date().toISOString();
270+
271+  recordLifecycleEvent(run.logSession, {
272+    type: "worker_started",
273+    level: "info",
274+    createdAt: executionStartedAt,
275+    message: `Worker runner opened execution scope for ${request.workerKind} step ${request.stepId}.`,
276+    data: {
277+      stepKind: request.stepKind,
278+      timeoutSec: request.timeoutSec,
279+      worktreePath: request.runtime.worktreePath
280+    }
281+  });
282+  synchronizeRunState(run, {
283+    status: "running",
284+    updatedAt: executionStartedAt
285+  });
286+
287+  const execution = await executor.execute(run);
288+  const blocked = execution.blocked ?? execution.outcome === "blocked";
289+  const needsHuman = execution.needsHuman ?? false;
290+  const exitCode = resolveExitCode(execution);
291+
292+  for (const line of execution.stdout ?? []) {
293+    appendStreamChunk(run.logSession, "stdout", {
294+      text: line
295+    });
296+  }
297+
298+  for (const line of execution.stderr ?? []) {
299+    appendStreamChunk(run.logSession, "stderr", {
300+      text: line
301+    });
302+  }
303+
304+  if (execution.outcome === "prepared") {
305+    recordLifecycleEvent(run.logSession, {
306+      type: "worker_execution_deferred",
307+      level: "info",
308+      message: `Real ${request.workerKind} execution is intentionally deferred in T-005.`,
309+      data: {
310+        deferredFeatures: run.checkpoint.deferredFeatures,
311+        nextCheckpointSeq: run.checkpoint.nextCheckpointSeq
312+      }
313+    });
314+  }
315+
316+  recordLifecycleEvent(run.logSession, {
317+    type: "worker_exited",
318+    level: exitCode === 0 ? "info" : "error",
319+    message: `Worker runner closed execution scope with outcome ${execution.outcome}.`,
320+    data: {
321+      exitCode,
322+      outcome: execution.outcome
323+    }
324+  });
325+
326+  const finishedAt = new Date().toISOString();
327+
328+  recordLifecycleEvent(run.logSession, {
329+    type: mapOutcomeToTerminalEvent(execution.outcome),
330+    level: mapOutcomeToLevel(execution),
331+    createdAt: finishedAt,
332+    message: execution.summary,
333+    data: {
334+      ok: execution.ok,
335+      blocked,
336+      needsHuman,
337+      exitCode
338+    }
339+  });
340+
341+  synchronizeRunState(run, {
342+    status: mapOutcomeToRunStatus(execution.outcome),
343+    updatedAt: finishedAt,
344+    finishedAt,
345+    summary: execution.summary,
346+    exitCode
347+  });
348+
349+  const logSummary = summarizeLocalRunLogSession(run.logSession);
350+  const durationMs = Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt));
351+
352+  return {
353+    ok: execution.ok,
354+    outcome: execution.outcome,
355+    summary: execution.summary,
356+    needsHuman,
357+    blocked,
358+    taskId: request.taskId,
359+    stepId: request.stepId,
360+    runId: request.runId,
361+    attempt: request.attempt,
362+    workerKind: request.workerKind,
363+    startedAt,
364+    finishedAt,
365+    durationMs,
366+    metadata: run.metadata,
367+    state: run.state,
368+    logPaths: run.logPaths,
369+    logSession: run.logSession,
370+    logSummary,
371+    lifecycleEvents: [...run.logSession.worker.entries],
372+    checkpoint: run.checkpoint,
373+    artifacts: mergeArtifacts(run, execution.artifacts),
374+    suggestedFollowup: execution.suggestedFollowup ?? [],
375+    metrics: {
376+      durationMs,
377+      lifecycleEventCount: logSummary.lifecycleEventCount,
378+      stdoutChunkCount: logSummary.stdoutChunkCount,
379+      stderrChunkCount: logSummary.stderrChunkCount,
380+      checkpointCount: 0
381+    }
382+  };
383+}
M apps/worker-runner/tsconfig.json
+7, -4
 1@@ -1,9 +1,12 @@
 2 {
 3   "extends": "../../tsconfig.base.json",
 4   "compilerOptions": {
 5-    "rootDir": "src",
 6-    "outDir": "dist"
 7+    "rootDir": "../..",
 8+    "outDir": "dist",
 9+    "baseUrl": "../..",
10+    "paths": {
11+      "@baa-conductor/logging": ["packages/logging/src/index.ts"]
12+    }
13   },
14-  "include": ["src/**/*.ts"]
15+  "include": ["src/**/*.ts", "../../packages/logging/src/**/*.ts"]
16 }
17-
M coordination/tasks/T-005-worker-runner.md
+23, -7
 1@@ -1,7 +1,7 @@
 2 ---
 3 task_id: T-005
 4 title: Worker Runner 与本地日志流
 5-status: todo
 6+status: review
 7 branch: feat/T-005-worker-runner
 8 repo: /Users/george/code/baa-conductor
 9 base_ref: main
10@@ -10,7 +10,7 @@ depends_on:
11 write_scope:
12   - apps/worker-runner/**
13   - packages/logging/**
14-updated_at: 2026-03-21
15+updated_at: 2026-03-21T19:41:39+0800
16 ---
17 
18 # T-005 Worker Runner 与本地日志流
19@@ -49,23 +49,39 @@ updated_at: 2026-03-21
20 
21 ## files_changed
22 
23-- 待填写
24+- `apps/worker-runner/src/index.ts`
25+- `apps/worker-runner/src/contracts.ts`
26+- `apps/worker-runner/src/runner.ts`
27+- `apps/worker-runner/tsconfig.json`
28+- `packages/logging/src/index.ts`
29+- `packages/logging/src/contracts.ts`
30+- `packages/logging/src/paths.ts`
31+- `packages/logging/src/session.ts`
32+- `packages/logging/src/state.ts`
33+- `coordination/tasks/T-005-worker-runner.md`
34 
35 ## commands_run
36 
37-- 待填写
38+- `npx -p typescript tsc --version`
39+- `npx -p typescript tsc --noEmit -p /Users/george/code/baa-conductor-t005/packages/logging/tsconfig.json`
40+- `npx -p typescript tsc --noEmit -p /Users/george/code/baa-conductor-t005/apps/worker-runner/tsconfig.json`
41+- `git -C /Users/george/code/baa-conductor-t005 diff --check`
42 
43 ## result
44 
45-- 待填写
46+- 建立了 `packages/logging` 的本地 run 路径约定、`meta/state` 数据结构、生命周期事件模型,以及 `worker.log/stdout.log/stderr.log` 的内存态抽象。
47+- 建立了 `apps/worker-runner` 的 step request/result、prepared run、默认占位 executor 与生命周期编排,明确区分 `prepared/completed/failed/blocked` 结果。
48+- 为 `T-006` 预留了 checkpoint 序号与目录接入点,但没有实现 checkpoint diff 或任何 checkpoint 内容落盘。
49 
50 ## risks
51 
52-- 待填写
53+- 当前实现只定义路径、事件和内存态会话;还没有真实文件写入,因此 `meta.json`、`state.json`、各类 log 仍未持久化。
54+- 当前 executor 仍是占位实现,没有接真实 Codex、Shell 或 Git worker,后续接入时还需要补真实进程生命周期与输出采集。
55 
56 ## next_handoff
57 
58-- 给 `T-006` 提供 checkpoint 接入点
59+- `T-006` 可以直接基于 `PreparedStepRun`、`StepCheckpointState` 和 `logPaths` 接入 checkpoint 目录写入与恢复逻辑。
60+- 后续 worker 执行接入时应复用当前 `StepExecutionResult` 和 logging session 结构,把真实 stdout/stderr/worker 事件落到同一套路径与结果模型。
61 
62 ## notes
63 
A packages/logging/src/contracts.ts
+188, -0
  1@@ -0,0 +1,188 @@
  2+export const DEFAULT_RUNS_DIRECTORY_NAME = "runs";
  3+export const DEFAULT_RUN_LAYOUT_VERSION = "local-run-v1";
  4+
  5+export type StructuredValue =
  6+  | string
  7+  | number
  8+  | boolean
  9+  | null
 10+  | StructuredValue[]
 11+  | { [key: string]: StructuredValue };
 12+
 13+export type StructuredData = Record<string, StructuredValue>;
 14+
 15+export type LogLevel = "debug" | "info" | "warn" | "error";
 16+export type WorkerLogChannel = "worker" | "stdout" | "stderr";
 17+export type StreamLogChannel = "stdout" | "stderr";
 18+export type RunStatus = "prepared" | "running" | "completed" | "failed" | "blocked";
 19+
 20+export type WorkerLifecycleEventType =
 21+  | "run_prepared"
 22+  | "checkpoint_slot_reserved"
 23+  | "worker_started"
 24+  | "worker_execution_deferred"
 25+  | "worker_exited"
 26+  | "step_prepared"
 27+  | "step_completed"
 28+  | "step_failed"
 29+  | "step_blocked";
 30+
 31+export interface LocalRunDirectoryRequest {
 32+  repoRoot: string;
 33+  taskId: string;
 34+  runId: string;
 35+  runsRootDir?: string;
 36+}
 37+
 38+export interface LocalRunPaths {
 39+  layoutVersion: string;
 40+  repoRoot: string;
 41+  runsRootDir: string;
 42+  taskRunsDir: string;
 43+  runDir: string;
 44+  checkpointsDir: string;
 45+  artifactsDir: string;
 46+  metaPath: string;
 47+  statePath: string;
 48+  workerLogPath: string;
 49+  stdoutLogPath: string;
 50+  stderrLogPath: string;
 51+}
 52+
 53+export interface LocalRunLogContext {
 54+  taskId: string;
 55+  stepId: string;
 56+  runId: string;
 57+}
 58+
 59+export interface LocalRunLogTarget {
 60+  channel: WorkerLogChannel;
 61+  filePath: string;
 62+}
 63+
 64+export interface LocalRunLogTargets {
 65+  worker: LocalRunLogTarget;
 66+  stdout: LocalRunLogTarget;
 67+  stderr: LocalRunLogTarget;
 68+}
 69+
 70+export interface WorkerLifecycleEventInput {
 71+  type: WorkerLifecycleEventType;
 72+  level: LogLevel;
 73+  message: string;
 74+  createdAt?: string;
 75+  data?: StructuredData;
 76+}
 77+
 78+export interface WorkerLifecycleLogEntry extends LocalRunLogContext {
 79+  seq: number;
 80+  channel: "worker";
 81+  eventId: string;
 82+  type: WorkerLifecycleEventType;
 83+  level: LogLevel;
 84+  createdAt: string;
 85+  message: string;
 86+  data: StructuredData;
 87+  renderedLine: string;
 88+}
 89+
 90+export interface StreamChunkInput {
 91+  text: string;
 92+  createdAt?: string;
 93+}
 94+
 95+export interface StreamChunkLogEntry extends LocalRunLogContext {
 96+  seq: number;
 97+  channel: StreamLogChannel;
 98+  createdAt: string;
 99+  text: string;
100+}
101+
102+export interface WorkerLifecycleLogStream {
103+  channel: "worker";
104+  filePath: string;
105+  entries: WorkerLifecycleLogEntry[];
106+}
107+
108+export interface StreamChunkLogStream {
109+  channel: StreamLogChannel;
110+  filePath: string;
111+  entries: StreamChunkLogEntry[];
112+}
113+
114+export interface LocalRunLogSession {
115+  context: LocalRunLogContext;
116+  paths: LocalRunPaths;
117+  nextSeq: number;
118+  worker: WorkerLifecycleLogStream;
119+  stdout: StreamChunkLogStream;
120+  stderr: StreamChunkLogStream;
121+}
122+
123+export interface LocalRunLogSummary {
124+  totalEntries: number;
125+  lastSeq: number;
126+  lifecycleEventCount: number;
127+  stdoutChunkCount: number;
128+  stderrChunkCount: number;
129+}
130+
131+export interface RunMetadata {
132+  layoutVersion: string;
133+  taskId: string;
134+  stepId: string;
135+  stepName: string;
136+  runId: string;
137+  workerKind: string;
138+  attempt: number;
139+  repoRoot: string;
140+  worktreePath: string;
141+  createdAt: string;
142+  checkpointMode: string;
143+  promptSummary?: string;
144+  command?: string;
145+}
146+
147+export interface RunMetadataInput {
148+  taskId: string;
149+  stepId: string;
150+  stepName: string;
151+  runId: string;
152+  workerKind: string;
153+  attempt: number;
154+  repoRoot: string;
155+  worktreePath: string;
156+  createdAt: string;
157+  checkpointMode: string;
158+  promptSummary?: string;
159+  command?: string;
160+}
161+
162+export interface RunStateSnapshot {
163+  status: RunStatus;
164+  attempt: number;
165+  startedAt: string;
166+  updatedAt: string;
167+  finishedAt?: string;
168+  lastEventSeq: number;
169+  checkpointSeq: number;
170+  summary?: string;
171+  exitCode?: number;
172+}
173+
174+export interface RunStateSnapshotInput {
175+  attempt: number;
176+  startedAt: string;
177+  checkpointSeq?: number;
178+  summary?: string;
179+}
180+
181+export interface RunStatePatch {
182+  status?: RunStatus;
183+  updatedAt?: string;
184+  finishedAt?: string;
185+  lastEventSeq?: number;
186+  checkpointSeq?: number;
187+  summary?: string;
188+  exitCode?: number;
189+}
M packages/logging/src/index.ts
+4, -21
 1@@ -1,21 +1,4 @@
 2-export type LogLevel = "debug" | "info" | "warn" | "error";
 3-
 4-export interface RunEvent {
 5-  seq: number;
 6-  level: LogLevel;
 7-  message: string;
 8-  createdAt: string;
 9-}
10-
11-export interface BufferedLogState {
12-  lastSeq: number;
13-  buffer: RunEvent[];
14-}
15-
16-export function createBufferedLogState(): BufferedLogState {
17-  return {
18-    lastSeq: 0,
19-    buffer: []
20-  };
21-}
22-
23+export * from "./contracts";
24+export * from "./paths";
25+export * from "./session";
26+export * from "./state";
A packages/logging/src/paths.ts
+76, -0
 1@@ -0,0 +1,76 @@
 2+import {
 3+  DEFAULT_RUN_LAYOUT_VERSION,
 4+  DEFAULT_RUNS_DIRECTORY_NAME,
 5+  type LocalRunDirectoryRequest,
 6+  type LocalRunLogTargets,
 7+  type LocalRunPaths
 8+} from "./contracts";
 9+
10+function trimTrailingSlashes(value: string): string {
11+  const trimmed = value.replace(/\/+$/u, "");
12+
13+  return trimmed === "" ? "/" : trimmed;
14+}
15+
16+function sanitizePathSegment(value: string): string {
17+  const trimmed = value.trim().replace(/^\/+|\/+$/gu, "");
18+
19+  if (trimmed === "") {
20+    return "_";
21+  }
22+
23+  return trimmed.replace(/[\\/]+/gu, "_");
24+}
25+
26+function joinPath(basePath: string, segment: string): string {
27+  const normalizedBasePath = trimTrailingSlashes(basePath);
28+
29+  if (normalizedBasePath === "/") {
30+    return `/${segment}`;
31+  }
32+
33+  return `${normalizedBasePath}/${segment}`;
34+}
35+
36+export function createLocalRunPaths(request: LocalRunDirectoryRequest): LocalRunPaths {
37+  const runsRootDir =
38+    request.runsRootDir === undefined
39+      ? joinPath(request.repoRoot, DEFAULT_RUNS_DIRECTORY_NAME)
40+      : trimTrailingSlashes(request.runsRootDir);
41+  const taskRunsDir = joinPath(runsRootDir, sanitizePathSegment(request.taskId));
42+  const runDir = joinPath(taskRunsDir, sanitizePathSegment(request.runId));
43+  const checkpointsDir = joinPath(runDir, "checkpoints");
44+  const artifactsDir = joinPath(runDir, "artifacts");
45+
46+  return {
47+    layoutVersion: DEFAULT_RUN_LAYOUT_VERSION,
48+    repoRoot: trimTrailingSlashes(request.repoRoot),
49+    runsRootDir,
50+    taskRunsDir,
51+    runDir,
52+    checkpointsDir,
53+    artifactsDir,
54+    metaPath: joinPath(runDir, "meta.json"),
55+    statePath: joinPath(runDir, "state.json"),
56+    workerLogPath: joinPath(runDir, "worker.log"),
57+    stdoutLogPath: joinPath(runDir, "stdout.log"),
58+    stderrLogPath: joinPath(runDir, "stderr.log")
59+  };
60+}
61+
62+export function createLocalRunLogTargets(paths: LocalRunPaths): LocalRunLogTargets {
63+  return {
64+    worker: {
65+      channel: "worker",
66+      filePath: paths.workerLogPath
67+    },
68+    stdout: {
69+      channel: "stdout",
70+      filePath: paths.stdoutLogPath
71+    },
72+    stderr: {
73+      channel: "stderr",
74+      filePath: paths.stderrLogPath
75+    }
76+  };
77+}
A packages/logging/src/session.ts
+134, -0
  1@@ -0,0 +1,134 @@
  2+import {
  3+  type LocalRunLogContext,
  4+  type LocalRunLogSession,
  5+  type LocalRunLogSummary,
  6+  type LocalRunPaths,
  7+  type StreamChunkInput,
  8+  type StreamChunkLogEntry,
  9+  type StreamChunkLogStream,
 10+  type StreamLogChannel,
 11+  type WorkerLifecycleEventInput,
 12+  type WorkerLifecycleLogEntry
 13+} from "./contracts";
 14+import { createLocalRunLogTargets } from "./paths";
 15+
 16+function allocateSeq(session: LocalRunLogSession): number {
 17+  const seq = session.nextSeq;
 18+  session.nextSeq += 1;
 19+
 20+  return seq;
 21+}
 22+
 23+function getStream(session: LocalRunLogSession, channel: StreamLogChannel): StreamChunkLogStream {
 24+  switch (channel) {
 25+    case "stdout":
 26+      return session.stdout;
 27+    case "stderr":
 28+      return session.stderr;
 29+  }
 30+}
 31+
 32+export function serializeLifecycleEvent(
 33+  entry: Omit<WorkerLifecycleLogEntry, "renderedLine">
 34+): string {
 35+  return JSON.stringify({
 36+    seq: entry.seq,
 37+    eventId: entry.eventId,
 38+    taskId: entry.taskId,
 39+    stepId: entry.stepId,
 40+    runId: entry.runId,
 41+    type: entry.type,
 42+    level: entry.level,
 43+    createdAt: entry.createdAt,
 44+    message: entry.message,
 45+    data: entry.data
 46+  });
 47+}
 48+
 49+export function createLocalRunLogSession(
 50+  paths: LocalRunPaths,
 51+  context: LocalRunLogContext
 52+): LocalRunLogSession {
 53+  const targets = createLocalRunLogTargets(paths);
 54+
 55+  return {
 56+    context,
 57+    paths,
 58+    nextSeq: 1,
 59+    worker: {
 60+      channel: "worker",
 61+      filePath: targets.worker.filePath,
 62+      entries: []
 63+    },
 64+    stdout: {
 65+      channel: "stdout",
 66+      filePath: targets.stdout.filePath,
 67+      entries: []
 68+    },
 69+    stderr: {
 70+      channel: "stderr",
 71+      filePath: targets.stderr.filePath,
 72+      entries: []
 73+    }
 74+  };
 75+}
 76+
 77+export function recordLifecycleEvent(
 78+  session: LocalRunLogSession,
 79+  input: WorkerLifecycleEventInput
 80+): WorkerLifecycleLogEntry {
 81+  const seq = allocateSeq(session);
 82+  const createdAt = input.createdAt ?? new Date().toISOString();
 83+  const baseEntry = {
 84+    ...session.context,
 85+    seq,
 86+    channel: "worker" as const,
 87+    eventId: `${session.context.runId}:event:${String(seq).padStart(4, "0")}`,
 88+    type: input.type,
 89+    level: input.level,
 90+    createdAt,
 91+    message: input.message,
 92+    data: input.data ?? {}
 93+  };
 94+  const entry: WorkerLifecycleLogEntry = {
 95+    ...baseEntry,
 96+    renderedLine: serializeLifecycleEvent(baseEntry)
 97+  };
 98+
 99+  session.worker.entries.push(entry);
100+
101+  return entry;
102+}
103+
104+export function appendStreamChunk(
105+  session: LocalRunLogSession,
106+  channel: StreamLogChannel,
107+  input: StreamChunkInput
108+): StreamChunkLogEntry {
109+  const seq = allocateSeq(session);
110+  const entry: StreamChunkLogEntry = {
111+    ...session.context,
112+    seq,
113+    channel,
114+    createdAt: input.createdAt ?? new Date().toISOString(),
115+    text: input.text
116+  };
117+
118+  getStream(session, channel).entries.push(entry);
119+
120+  return entry;
121+}
122+
123+export function summarizeLocalRunLogSession(session: LocalRunLogSession): LocalRunLogSummary {
124+  const lifecycleEventCount = session.worker.entries.length;
125+  const stdoutChunkCount = session.stdout.entries.length;
126+  const stderrChunkCount = session.stderr.entries.length;
127+
128+  return {
129+    totalEntries: lifecycleEventCount + stdoutChunkCount + stderrChunkCount,
130+    lastSeq: session.nextSeq - 1,
131+    lifecycleEventCount,
132+    stdoutChunkCount,
133+    stderrChunkCount
134+  };
135+}
A packages/logging/src/state.ts
+54, -0
 1@@ -0,0 +1,54 @@
 2+import {
 3+  DEFAULT_RUN_LAYOUT_VERSION,
 4+  type RunMetadata,
 5+  type RunMetadataInput,
 6+  type RunStatePatch,
 7+  type RunStateSnapshot,
 8+  type RunStateSnapshotInput
 9+} from "./contracts";
10+
11+export function createRunMetadata(input: RunMetadataInput): RunMetadata {
12+  return {
13+    layoutVersion: DEFAULT_RUN_LAYOUT_VERSION,
14+    taskId: input.taskId,
15+    stepId: input.stepId,
16+    stepName: input.stepName,
17+    runId: input.runId,
18+    workerKind: input.workerKind,
19+    attempt: input.attempt,
20+    repoRoot: input.repoRoot,
21+    worktreePath: input.worktreePath,
22+    createdAt: input.createdAt,
23+    checkpointMode: input.checkpointMode,
24+    promptSummary: input.promptSummary,
25+    command: input.command
26+  };
27+}
28+
29+export function createRunStateSnapshot(input: RunStateSnapshotInput): RunStateSnapshot {
30+  return {
31+    status: "prepared",
32+    attempt: input.attempt,
33+    startedAt: input.startedAt,
34+    updatedAt: input.startedAt,
35+    lastEventSeq: 0,
36+    checkpointSeq: input.checkpointSeq ?? 0,
37+    summary: input.summary
38+  };
39+}
40+
41+export function updateRunState(
42+  snapshot: RunStateSnapshot,
43+  patch: RunStatePatch
44+): RunStateSnapshot {
45+  return {
46+    ...snapshot,
47+    status: patch.status ?? snapshot.status,
48+    updatedAt: patch.updatedAt ?? snapshot.updatedAt,
49+    finishedAt: patch.finishedAt ?? snapshot.finishedAt,
50+    lastEventSeq: patch.lastEventSeq ?? snapshot.lastEventSeq,
51+    checkpointSeq: patch.checkpointSeq ?? snapshot.checkpointSeq,
52+    summary: patch.summary ?? snapshot.summary,
53+    exitCode: patch.exitCode ?? snapshot.exitCode
54+  };
55+}