Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoju 9c26285424 chore: make solve-issue.yaml portable and add developer failed exit
- Remove hardcoded ~/repos/workflow paths from procedure text
- Use .worktrees/ relative to repo root instead of global path
- Add developer failed → $END exit for unrecoverable situations
- Add worktree field to reviewer rejected variant
- Fix test workflowPath to use import.meta.dirname

Refs #506
2026-05-25 09:07:58 +00:00
xiaoju 45f479e60f feat(protocol): add step-level timing (startedAtMs / completedAtMs) (#489)
BREAKING CHANGE: StepRecord now requires startedAtMs and completedAtMs fields.
StepEntry now requires durationMs field. Old CAS data without these fields is invalid.

- Add startedAtMs/completedAtMs to StepRecord and StepNodePayload
- Add durationMs to StepEntry (computed: completedAtMs - startedAtMs)
- Update STEP_NODE_SCHEMA to require timing fields as integers
- Record Date.now() before/after agent execution in createAgent
- Show duration in thread read headers (formatStepHeader)
- Update existing test fixtures with timing fields
2026-05-25 08:01:50 +00:00
12 changed files with 527 additions and 50 deletions
+52 -43
View File
@@ -10,9 +10,9 @@ roles:
procedure: |
On first run (no previous steps):
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
2. Read CLAUDE.md (or equivalent project conventions file) to understand coding standards
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
3. Assess whether the issue has enough information to produce a test spec
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output status=insufficient_info and terminate
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
On subsequent runs (bounced back by tester with fix_spec):
@@ -21,7 +21,8 @@ roles:
After producing the test spec:
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
2. Put the hash in frontmatter.plan (required when status=ready)
2. Put the hash in frontmatter.plan (required when $status=ready)
3. Set repoPath to the absolute path of the repository root
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
frontmatter:
oneOf:
@@ -40,32 +41,41 @@ roles:
- coding
procedure: |
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
The repo path and other details are provided in your task prompt.
Before starting any work, set up an isolated worktree:
1. `cd ~/repos/workflow && git fetch origin` to get latest refs
2. First time (no existing branch):
- `git worktree add ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
- `cd ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug> && bun install`
3. If bounced back from reviewer or tester (branch already exists):
- The worktree should already exist at `~/repos/workflow-worktrees/fix/<issue-number>-<short-slug>`
- `cd ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug>`
1. cd into the repo path provided in your task prompt
2. `git fetch origin` to get latest refs
3. First time (no existing branch):
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
4. If bounced back from reviewer or tester (branch already exists):
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
- `git fetch origin && git rebase origin/main`
4. ALL subsequent work must happen inside the worktree directory.
5. ALL subsequent work must happen inside the worktree directory.
Then implement TDD:
5. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
6. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
7. Write tests first based on the spec
8. Implement the code to make tests pass
9. Ensure `bun run build` passes with no errors
10. Run `bun test` to verify all tests pass
output: "List all files changed and provide a summary. Include branch name and worktree path in frontmatter."
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
8. Write tests first based on the spec
9. Implement the code to make tests pass
10. Ensure `bun run build` passes with no errors
11. Run `bun test` to verify all tests pass
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
or repeated attempts fail), set $status=failed with a reason.
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
type: object
properties:
branch: { type: string }
worktree: { type: string }
required: [branch, worktree]
oneOf:
- properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
reviewer:
description: "Code standards compliance check"
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
@@ -73,7 +83,7 @@ roles:
- code-review
- static-analysis
procedure: |
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
The worktree path is provided in your task prompt. cd into it first.
Before reviewing, verify the git branch:
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
@@ -85,12 +95,9 @@ roles:
4. `bunx biome check` — no lint violations
5. TypeScript strict mode — no type errors
Soft checks (review against CLAUDE.md conventions):
- Functional-first: `function` + `type`, not `class` + `interface`
- No optional properties (`?:`) — use `T | null`
- Naming conventions (kebab-case files, PascalCase types, camelCase functions)
- Module boundary discipline (folder exports via index.ts)
- No `console.log` (use structured logger)
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
- Naming conventions, module boundaries, code style
- No `console.log` in production code
- No dynamic imports in production code
Only review standards compliance. Do NOT test functionality.
@@ -106,17 +113,18 @@ roles:
- properties:
$status: { const: "rejected" }
comments: { type: string }
required: [$status, comments]
worktree: { type: string }
required: [$status, comments, worktree]
tester:
description: "Functional correctness verification"
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
capabilities:
- testing
procedure: |
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
The worktree path is provided in your task prompt. cd into it first.
1. Run `bun test` for automated test verification
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
3. Verify each scenario in the spec is covered and passing
4. Determine outcome:
- passed: all scenarios verified, tests pass
@@ -143,21 +151,21 @@ roles:
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
capabilities: []
procedure: |
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
The worktree path, branch name, and repo info are provided in your task prompt.
cd into the worktree first.
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Stage all changes: `git add -A`
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
3. Push the branch: `git push -u origin <branch-name>`
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --repo uncaged/workflow --title "..." --description "..."`
- The `--repo` flag is required to work in worktree directories (fixes #474 "path segment [0] is empty" error)
- If working on a different repo, extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
- On tea failure: capture stderr/stdout, log the error clearly, include PR details (title, description, branch) for manual creation, and mark success=false
4. On push success: create a PR via `tea pr create --repo <owner/repo> --title "..." --description "..."`
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
5. After PR creation, clean up the worktree:
- `cd ~/repos/workflow`
- `git worktree remove ~/repos/workflow-worktrees/fix/<issue-number>-<slug>`
- cd to the repo root (parent of .worktrees)
- `git worktree remove <worktree-path>`
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
@@ -176,9 +184,10 @@ graph:
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
developer:
_: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues." }
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
tester:
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
@@ -13,8 +13,16 @@ import { parse } from "yaml";
*/
describe("solve-issue workflow: tea pr create worktree fix", () => {
// Navigate up from packages/cli-workflow to repo root
const workflowPath = join(process.cwd(), "..", "..", ".workflows", "solve-issue.yaml");
// Navigate up from packages/cli-workflow/src/__tests__ to repo root
const workflowPath = join(
import.meta.dirname,
"..",
"..",
"..",
"..",
".workflows",
"solve-issue.yaml",
);
test("committer procedure should include --repo flag in tea pr create command", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
@@ -144,6 +144,8 @@ describe("step read", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step with large quota
@@ -227,6 +229,8 @@ describe("step read", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step with limited quota (700 chars)
@@ -304,6 +308,8 @@ describe("step read", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step with minimal quota (1 char)
@@ -357,6 +363,8 @@ describe("step read", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step - should return metadata only (no error)
@@ -431,6 +439,8 @@ describe("step read", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step - should return metadata only (no error)
@@ -505,6 +515,8 @@ describe("step read", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step
@@ -0,0 +1,378 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { STEP_NODE_SCHEMA } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepList } from "../commands/step.js";
import { cmdThreadRead } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import { saveThreadsIndex } from "../store.js";
// ── schemas ──────────────────────────────────────────────────────────────────
const TURN_SCHEMA = {
title: "hermes-turn",
type: "object" as const,
required: ["index", "role", "content"],
properties: {
index: { type: "integer" as const },
role: { type: "string" as const },
content: { type: "string" as const },
toolCalls: {
anyOf: [
{ type: "array" as const, items: { type: "object" as const } },
{ type: "null" as const },
],
},
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
},
additionalProperties: false,
};
const DETAIL_SCHEMA = {
title: "hermes-detail",
type: "object" as const,
required: ["sessionId", "model", "duration", "turnCount", "turns"],
properties: {
sessionId: { type: "string" as const },
model: { type: "string" as const },
duration: { type: "integer" as const },
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "cas_ref" },
},
},
additionalProperties: false,
};
// ── helpers ──────────────────────────────────────────────────────────────────
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
await bootstrap(store);
const [turn, detail] = await Promise.all([
putSchema(store, TURN_SCHEMA),
putSchema(store, DETAIL_SCHEMA),
]);
return { turn, detail };
}
// ── fixture ──────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-timing-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── 1. Protocol types (compile-time) ─────────────────────────────────────────
describe("protocol types", () => {
test("StepRecord has startedAtMs and completedAtMs as required fields", () => {
// Type-level test: this block compiles only if fields exist and are number
const record: import("@uncaged/workflow-protocol").StepRecord = {
role: "test",
output: "hash1" as CasRef,
detail: "hash2" as CasRef,
agent: "uwf-test",
edgePrompt: "",
startedAtMs: 1000,
completedAtMs: 2000,
};
expect(record.startedAtMs).toBe(1000);
expect(record.completedAtMs).toBe(2000);
});
test("StepEntry has durationMs as required field", () => {
const entry: import("@uncaged/workflow-protocol").StepEntry = {
hash: "hash" as CasRef,
role: "test",
output: {},
detail: "hash2" as CasRef,
agent: "uwf-test",
timestamp: 123,
durationMs: 5000,
};
expect(entry.durationMs).toBe(5000);
});
});
// ── 2. JSON Schema ───────────────────────────────────────────────────────────
describe("StepNode JSON schema", () => {
test("schema requires startedAtMs and completedAtMs", () => {
const required = STEP_NODE_SCHEMA.required as string[];
expect(required).toContain("startedAtMs");
expect(required).toContain("completedAtMs");
});
test("schema defines timing fields as integer", () => {
const props = STEP_NODE_SCHEMA.properties as Record<string, { type: string }>;
expect(props.startedAtMs.type).toBe("integer");
expect(props.completedAtMs.type).toBe("integer");
});
test("StepNode with timing fields passes CAS validation", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const startHash = await store.put(schemas.startNode, {
workflow: "placeholder0000" as CasRef,
prompt: "test",
});
const outputHash = await store.put(schemas.text, "output text");
const detailSchemas = await registerDetailSchemas(store);
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "s1",
model: "m1",
duration: 100,
turnCount: 0,
turns: [],
});
// Should succeed — valid timing fields
const hash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
edgePrompt: "",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
expect(hash).toBeTruthy();
});
});
// ── 3. step list — durationMs computed ───────────────────────────────────────
describe("step list timing", () => {
test("step list includes durationMs = completedAtMs - startedAtMs", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "test",
});
const outputHash = await store.put(schemas.text, "output");
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "s1",
model: "m1",
duration: 100,
turnCount: 0,
turns: [],
});
const startedAt = 1716600000000;
const completedAt = 1716600003500;
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
edgePrompt: "",
startedAtMs: startedAt,
completedAtMs: completedAt,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const result = await cmdStepList(tmpDir, threadId);
const stepEntries = result.steps.slice(1); // skip start entry
expect(stepEntries).toHaveLength(1);
const step = stepEntries[0] as import("@uncaged/workflow-protocol").StepEntry;
expect(step.durationMs).toBe(3500);
});
});
// ── 4. thread read — duration in header ──────────────────────────────────────
describe("thread read timing", () => {
test("thread read header includes Duration", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "Do work",
capabilities: [],
procedure: "work",
output: "result",
frontmatter: "placeholder0000" as CasRef,
},
},
graph: {
$START: { _: { role: "worker", prompt: "go" } },
worker: { _: { role: "$END", prompt: "" } },
},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "test task",
});
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "Done.",
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "s1",
model: "m1",
duration: 100,
turnCount: 1,
turns: [turnHash],
});
const outputHash = await store.put(schemas.text, "output");
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
edgePrompt: "",
startedAtMs: 1716600000000,
completedAtMs: 1716600042000,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ3" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, 10000, null, false);
expect(markdown).toContain("**Duration:** 42.0s");
});
test("thread read shows sub-second duration as ms", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "Do work",
capabilities: [],
procedure: "work",
output: "result",
frontmatter: "placeholder0000" as CasRef,
},
},
graph: {
$START: { _: { role: "worker", prompt: "go" } },
worker: { _: { role: "$END", prompt: "" } },
},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "test",
});
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "Done.",
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "s1",
model: "m1",
duration: 100,
turnCount: 1,
turns: [turnHash],
});
const outputHash = await store.put(schemas.text, "output");
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
edgePrompt: "",
startedAtMs: 1716600000000,
completedAtMs: 1716600000350,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, 10000, null, false);
expect(markdown).toContain("**Duration:** 350ms");
});
});
// ── 6. Breaking change — old data without timing fails ───────────────────────
describe("breaking change", () => {
test("StepNode schema rejects payload without timing fields", () => {
const required = STEP_NODE_SCHEMA.required as string[];
// Both fields must be in the required array
expect(required).toContain("startedAtMs");
expect(required).toContain("completedAtMs");
// Payload without timing fields would fail schema validation
// because the schema marks them as required
const payloadWithoutTiming = {
start: "hash1",
prev: null,
role: "worker",
output: "hash2",
detail: "hash3",
agent: "uwf-test",
edgePrompt: "",
};
// Verify the payload is missing required fields
expect(payloadWithoutTiming).not.toHaveProperty("startedAtMs");
expect(payloadWithoutTiming).not.toHaveProperty("completedAtMs");
});
});
@@ -141,6 +141,8 @@ describe("thread read --quota flag", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
steps.push(stepHash);
}
@@ -221,6 +223,8 @@ describe("thread read --quota flag", () => {
output: outputHash,
detail: step1DetailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const step2Content = generateContent(600, "Second");
@@ -245,6 +249,8 @@ describe("thread read --quota flag", () => {
output: outputHash,
detail: step2DetailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
@@ -328,6 +334,8 @@ describe("thread read --quota flag", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
steps.push(stepHash);
}
@@ -338,8 +346,8 @@ describe("thread read --quota flag", () => {
// Set tight quota with --start flag
const markdown = await cmdThreadRead(tmpDir, threadId, 600, null, true);
// Quota must be reasonably enforced (allow ~210 char tolerance for structure)
expect(markdown.length).toBeLessThanOrEqual(810);
// Quota must be reasonably enforced (allow ~260 char tolerance for structure)
expect(markdown.length).toBeLessThanOrEqual(860);
// Should contain thread header
expect(markdown).toMatch(/# Thread/);
@@ -405,6 +413,8 @@ describe("thread read --quota flag", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
@@ -480,6 +490,8 @@ describe("thread read --quota flag", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
steps.push(stepHash);
}
@@ -559,6 +571,8 @@ describe("thread read --quota flag", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
steps.push(stepHash);
}
@@ -139,6 +139,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-claude-code",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000001" as ThreadId;
@@ -214,6 +216,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-claude-code",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000002" as ThreadId;
@@ -274,6 +278,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
@@ -283,6 +289,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000003" as ThreadId;
@@ -335,6 +343,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000004" as ThreadId;
@@ -387,6 +397,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: missingDetailRef,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000005" as ThreadId;
@@ -439,6 +451,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000006" as ThreadId;
@@ -511,6 +525,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
@@ -520,6 +536,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const step3 = await uwf.store.put(uwf.schemas.stepNode, {
@@ -529,6 +547,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000007" as ThreadId;
@@ -607,6 +627,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000008" as ThreadId;
@@ -661,6 +683,8 @@ describe("thread read XML tag isolation", () => {
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
})) as CasRef;
steps.push(step);
prev = step;
@@ -58,6 +58,7 @@ export async function cmdStepList(
detail: item.payload.detail ?? null,
agent: item.payload.agent,
timestamp: item.timestamp,
durationMs: item.payload.completedAtMs - item.payload.startedAtMs,
});
}
+12 -1
View File
@@ -566,14 +566,25 @@ function selectByQuota(
return { selected, skippedCount: candidates.length - selected.length };
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = ms / 1000;
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const minutes = Math.floor(seconds / 60);
const remainingSec = Math.round(seconds % 60);
return `${minutes}m${remainingSec}s`;
}
function formatStepHeader(stepNum: number, item: OrderedStepItem): string {
const ts = new Date(item.timestamp)
.toISOString()
.replace("T", " ")
.replace(/\.\d+Z$/, "");
const durationMs = item.payload.completedAtMs - item.payload.startedAtMs;
const duration = formatDuration(durationMs);
return [
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
`**Agent:** ${item.payload.agent} | **Time:** ${ts} | **Duration:** ${duration}`,
].join("\n");
}
@@ -128,6 +128,8 @@ async function buildHistory(
detail: step.detail,
agent: step.agent,
edgePrompt: step.edgePrompt ?? "",
startedAtMs: step.startedAtMs,
completedAtMs: step.completedAtMs,
content,
});
}
+12 -1
View File
@@ -59,6 +59,8 @@ async function writeStepNode(options: {
detailHash: CasRef;
agentName: string;
edgePrompt: string;
startedAtMs: number;
completedAtMs: number;
}): Promise<CasRef> {
const payload: StepNodePayload = {
start: options.startHash,
@@ -68,6 +70,8 @@ async function writeStepNode(options: {
detail: options.detailHash,
agent: options.agentName,
edgePrompt: options.edgePrompt,
startedAtMs: options.startedAtMs,
completedAtMs: options.completedAtMs,
};
const hash = await options.store.put(options.schemas.stepNode, payload);
const node = options.store.get(hash);
@@ -94,6 +98,8 @@ async function persistStep(options: {
outputHash: CasRef;
detailHash: CasRef;
agentName: string;
startedAtMs: number;
completedAtMs: number;
}): Promise<CasRef> {
const { store, schemas, chain, headHash } = options.ctx.meta;
return writeStepNode({
@@ -106,6 +112,8 @@ async function persistStep(options: {
detailHash: options.detailHash,
agentName: options.agentName,
edgePrompt: options.ctx.edgePrompt,
startedAtMs: options.startedAtMs,
completedAtMs: options.completedAtMs,
});
}
@@ -127,6 +135,7 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
}
const startedAtMs = Date.now();
let agentResult = await runWithMessage("agent run failed", () => options.run(ctx));
// Preserve the primary detail from the first run — it contains the full
@@ -156,12 +165,14 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
`Raw output (first 500 chars): ${agentResult.output.slice(0, 500)}`,
);
}
const completedAtMs = Date.now();
const stepHash = await persistStep({
ctx,
outputHash,
detailHash: primaryDetailHash,
agentName: agentLabel(options.name),
startedAtMs,
completedAtMs,
});
process.stdout.write(`${stepHash}\n`);
+3 -1
View File
@@ -60,7 +60,7 @@ export const START_NODE_SCHEMA: JSONSchema = {
export const STEP_NODE_SCHEMA: JSONSchema = {
title: "StepNode",
type: "object",
required: ["start", "prev", "role", "output", "detail", "agent"],
required: ["start", "prev", "role", "output", "detail", "agent", "startedAtMs", "completedAtMs"],
properties: {
start: { type: "string", format: "cas_ref" },
prev: {
@@ -71,6 +71,8 @@ export const STEP_NODE_SCHEMA: JSONSchema = {
detail: { type: "string", format: "cas_ref" },
agent: { type: "string" },
edgePrompt: { type: "string" },
startedAtMs: { type: "integer" },
completedAtMs: { type: "integer" },
},
additionalProperties: false,
};
+5
View File
@@ -14,6 +14,10 @@ export type StepRecord = {
agent: string;
/** Moderator edge prompt that led to this step. Missing in legacy nodes → "". */
edgePrompt: string;
/** Date.now() before agent spawn */
startedAtMs: number;
/** Date.now() after agent returns */
completedAtMs: number;
};
// ── 4.2 Workflow 定义 ───────────────────────────────────────────────
@@ -89,6 +93,7 @@ export type StepEntry = {
detail: CasRef;
agent: string;
timestamp: number;
durationMs: number;
};
/** uwf thread steps — start entry */