From 1e8d009e9531d71cef0762998663b4e3731032c6 Mon Sep 17 00:00:00 2001 From: HWLAB Code Queue Date: Thu, 21 May 2026 15:49:53 +0000 Subject: [PATCH] m4: add local agent loop smoke --- docs/m4-agent-loop.md | 44 ++++ .../evidence/m4-agent-loop-plan.md | 11 + fixtures/mvp/m4-agent-loop/runtime.json | 14 ++ scripts/m4-agent-loop-smoke.mjs | 219 ++++++++++++++++++ 4 files changed, 288 insertions(+) create mode 100644 docs/m4-agent-loop.md create mode 100644 fixtures/mvp/m4-agent-loop/evidence/m4-agent-loop-plan.md create mode 100644 fixtures/mvp/m4-agent-loop/runtime.json create mode 100644 scripts/m4-agent-loop-smoke.mjs diff --git a/docs/m4-agent-loop.md b/docs/m4-agent-loop.md new file mode 100644 index 00000000..82680fc2 --- /dev/null +++ b/docs/m4-agent-loop.md @@ -0,0 +1,44 @@ +# M4 Agent Automation Loop Local Contract Smoke + +Run the local M4 smoke from the repository root: + +```sh +node scripts/m4-agent-loop-smoke.mjs +``` + +This check stays local. It uses the `internal/agent` runtime skeleton and a +repo fixture to verify: + +- lifecycle coverage for `create`, `start`, `trace`, `finish`, and `cleanup`; +- workspace isolation per agent session; +- explicit skills `commitId` wiring; +- evidence record fields, URI shape, and SHA-256 digest; +- the fixture evidence note that marks the run as local-only. + +## Real DEV Preconditions + +A real DEV M4 run is separate from this smoke. It would need a live DEV +environment, a seeded `prj_*` project, the `hwlab-agent-mgr`, +`hwlab-agent-worker`, and `hwlab-agent-skills` services, and a stable +`hwlab-agent-skills` artifact with an explicit `commitId`. + +That live path must not read tokens or secrets from the runner. Do not inspect +`GH_TOKEN`, `GITHUB_TOKEN`, kubeconfig material, tunnel credentials, or any +other secret-bearing source as part of this smoke. If a live M4 path cannot run +without those reads, the blocker is safety, not contract shape. + +## Failure Classification + +- `contract_blocker`: fixture, lifecycle, or field shape mismatch. +- `environment_blocker`: DEV-only boundary or endpoint assumptions are wrong. +- `agent_blocker`: workspace isolation, cleanup, or evidence wiring fails. +- `observability_blocker`: a live artifact cannot be tied to service or + `commitId` identity. +- `safety_blocker`: any secret read, real deployment, or long-running agent + task. + +## What This Is Not + +This does not deploy, does not call production, and does not run a real agent +job. It only checks the local runtime skeleton contract and its fixture-backed +evidence note. diff --git a/fixtures/mvp/m4-agent-loop/evidence/m4-agent-loop-plan.md b/fixtures/mvp/m4-agent-loop/evidence/m4-agent-loop-plan.md new file mode 100644 index 00000000..18810a97 --- /dev/null +++ b/fixtures/mvp/m4-agent-loop/evidence/m4-agent-loop-plan.md @@ -0,0 +1,11 @@ +# M4 Agent Automation Loop Evidence Plan + +- Scenario: m4-agent-automation-loop-local-contract-smoke +- Environment: dev +- Project: prj_m4_agent_loop +- Agent session: agt_m4_agent_loop_01 +- Worker session: wkr_agt_m4_agent_loop_01 +- Operation: op_agt_m4_agent_loop_01 +- Evidence: evi_agt_m4_agent_loop_01 +- Skill commitId: 6509a35 +- Boundary: local only; no secrets, no live DEV agent run, no long-running task diff --git a/fixtures/mvp/m4-agent-loop/runtime.json b/fixtures/mvp/m4-agent-loop/runtime.json new file mode 100644 index 00000000..3b6923b5 --- /dev/null +++ b/fixtures/mvp/m4-agent-loop/runtime.json @@ -0,0 +1,14 @@ +{ + "scenario": "m4-agent-automation-loop-local-contract-smoke", + "environment": "dev", + "projectId": "prj_m4_agent_loop", + "agentSessionId": "agt_m4_agent_loop_01", + "goal": "verify M4 agent automation loop skeleton", + "prompt": "local contract smoke for agent automation loop", + "skillCommitId": "6509a35", + "evidencePath": "fixtures/mvp/m4-agent-loop/evidence/m4-agent-loop-plan.md", + "workspaceIsolation": { + "projectId": "prj_m4_agent_loop_alt", + "agentSessionId": "agt_m4_agent_loop_02" + } +} diff --git a/scripts/m4-agent-loop-smoke.mjs b/scripts/m4-agent-loop-smoke.mjs new file mode 100644 index 00000000..27315f84 --- /dev/null +++ b/scripts/m4-agent-loop-smoke.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + AGENT_MGR_SERVICE_ID, + AGENT_SESSION_STATUSES, + AGENT_SKILLS_SERVICE_ID, + AGENT_WORKER_SERVICE_ID, + WORKER_SESSION_STATUSES, + createAgentRunPlan, + createSkillsManifest, + finalizeAgentRun +} from "../internal/agent/index.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const fixedNow = () => "2026-05-21T00:00:00.000Z"; + +async function readJSON(relativePath) { + const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); + return JSON.parse(raw); +} + +async function readText(relativePath) { + return readFile(path.join(repoRoot, relativePath), "utf8"); +} + +function sha256Hex(value) { + return createHash("sha256").update(String(value)).digest("hex"); +} + +function expectLifecycleConstants() { + assert.deepEqual(AGENT_SESSION_STATUSES, ["create", "start", "trace", "finish", "cleanup"]); + assert.deepEqual(WORKER_SESSION_STATUSES, ["create", "start", "trace", "finish", "cleanup"]); +} + +function expectRunPlan(plan, fixture) { + const workerSessionId = `wkr_${fixture.agentSessionId}`; + const traceId = `trc_${fixture.agentSessionId}`; + const operationId = `op_${fixture.agentSessionId}`; + const evidenceId = `evi_${fixture.agentSessionId}`; + const workspaceVolumeId = `vol_${fixture.agentSessionId}`; + const mountPath = `/workspace/${fixture.projectId}/${fixture.agentSessionId}`; + const evidenceUri = `file://${path.posix.join(mountPath, "evidence.txt")}`; + + assert.equal(plan.skillCommitId, fixture.skillCommitId); + assert.equal(plan.agentSession.serviceId, AGENT_MGR_SERVICE_ID); + assert.equal(plan.workerSession.serviceId, AGENT_WORKER_SERVICE_ID); + assert.equal(plan.agentSession.agentSessionId, fixture.agentSessionId); + assert.equal(plan.agentSession.projectId, fixture.projectId); + assert.equal(plan.agentSession.environment, fixture.environment); + assert.equal(plan.agentSession.status, "start"); + assert.equal(plan.agentSession.startedAt, fixedNow()); + assert.equal(plan.agentSession.updatedAt, fixedNow()); + assert.equal(plan.agentSession.metadata.currentState, "start"); + assert.equal(plan.agentSession.metadata.workerSessionId, workerSessionId); + assert.equal(plan.agentSession.metadata.workspaceVolumeId, workspaceVolumeId); + assert.equal(plan.workerSession.workerSessionId, workerSessionId); + assert.equal(plan.workerSession.agentSessionId, fixture.agentSessionId); + assert.equal(plan.workerSession.projectId, fixture.projectId); + assert.equal(plan.workerSession.environment, fixture.environment); + assert.equal(plan.workerSession.status, "start"); + assert.equal(plan.workerSession.startedAt, fixedNow()); + assert.equal(plan.workerSession.updatedAt, fixedNow()); + assert.equal(plan.workerSession.metadata.currentState, "start"); + assert.equal(plan.workerSession.metadata.workspaceVolumeId, workspaceVolumeId); + assert.equal(plan.workspaceVolume.workspaceVolumeId, workspaceVolumeId); + assert.equal(plan.workspaceVolume.mountPath, mountPath); + assert.equal(plan.workspaceVolume.serviceId, AGENT_WORKER_SERVICE_ID); + assert.equal(plan.workspaceVolume.lifecycle, "create"); + assert.equal(plan.workspaceVolume.createdAt, fixedNow()); + assert.equal(plan.workspaceVolume.updatedAt, fixedNow()); + assert.deepEqual( + plan.events.map((event) => event.state), + ["create", "start", "trace", "finish", "cleanup"] + ); + assert.equal(plan.events[0].actor, AGENT_MGR_SERVICE_ID); + assert.equal(plan.events[0].agentSessionId, fixture.agentSessionId); + assert.equal(plan.events[1].actor, AGENT_WORKER_SERVICE_ID); + assert.equal(plan.events[1].workerSessionId, workerSessionId); + assert.equal(plan.events[2].actor, AGENT_MGR_SERVICE_ID); + assert.equal(plan.events[2].traceEvent.traceEventId, `tev_${fixture.agentSessionId}`); + assert.equal(plan.events[2].traceEvent.traceId, traceId); + assert.equal(plan.events[2].traceEvent.projectId, fixture.projectId); + assert.equal(plan.events[2].traceEvent.serviceId, AGENT_MGR_SERVICE_ID); + assert.equal(plan.events[2].traceEvent.level, "info"); + assert.equal(plan.events[2].traceEvent.message, fixture.prompt); + assert.equal(plan.events[2].traceEvent.environment, fixture.environment); + assert.equal(plan.events[2].traceEvent.agentSessionId, fixture.agentSessionId); + assert.equal(plan.events[2].traceEvent.workerSessionId, workerSessionId); + assert.equal(plan.events[2].traceEvent.operationId, operationId); + assert.deepEqual(plan.events[2].traceEvent.metadata, {}); + assert.equal(plan.traceEvent.traceId, traceId); + assert.equal(plan.traceEvent.message, fixture.prompt); + assert.equal(plan.traceEvent.occurredAt, fixedNow()); + assert.equal(plan.events[3].actor, AGENT_WORKER_SERVICE_ID); + assert.equal(plan.events[3].evidenceRecord.evidenceId, evidenceId); + assert.equal(plan.events[4].actor, AGENT_WORKER_SERVICE_ID); + assert.equal(plan.events[4].workspaceVolumeId, workspaceVolumeId); + assert.equal(plan.evidenceRecord.evidenceId, evidenceId); + assert.equal(plan.evidenceRecord.projectId, fixture.projectId); + assert.equal(plan.evidenceRecord.operationId, operationId); + assert.equal(plan.evidenceRecord.agentSessionId, fixture.agentSessionId); + assert.equal(plan.evidenceRecord.workerSessionId, workerSessionId); + assert.equal(plan.evidenceRecord.kind, "artifact"); + assert.equal(plan.evidenceRecord.uri, evidenceUri); + assert.equal(plan.evidenceRecord.mimeType, "text/plain"); + assert.equal(plan.evidenceRecord.sha256, sha256Hex(evidenceUri)); + assert.equal(plan.evidenceRecord.sizeBytes, 0); + assert.equal(plan.evidenceRecord.serviceId, AGENT_WORKER_SERVICE_ID); + assert.equal(plan.evidenceRecord.environment, fixture.environment); + assert.equal(plan.evidenceRecord.createdAt, fixedNow()); + assert.equal(plan.evidenceRecord.metadata.skillCommitId, fixture.skillCommitId); + assert.equal(plan.evidenceRecord.metadata.prompt, fixture.prompt); + assert.equal(plan.evidenceRecord.metadata.goal, fixture.goal); + assert.equal(plan.evidenceRecord.metadata.workspaceVolumeId, workspaceVolumeId); +} + +function expectFinishedRun(finished, originalPlan) { + assert.equal(finished.agentSession.status, "cleanup"); + assert.equal(finished.workerSession.status, "cleanup"); + assert.equal(finished.agentSession.completedAt, fixedNow()); + assert.equal(finished.workerSession.completedAt, fixedNow()); + assert.equal(finished.agentSession.metadata.cleanedUp, true); + assert.equal(finished.workerSession.metadata.cleanedUp, true); + assert.equal(finished.cleanup.workspaceVolumeId, originalPlan.workspaceVolume.workspaceVolumeId); + assert.equal(finished.cleanup.removed, true); + assert.equal(finished.evidenceRecord.sha256, originalPlan.evidenceRecord.sha256); + assert.equal(originalPlan.agentSession.status, "start"); + assert.equal(originalPlan.workerSession.status, "start"); +} + +function expectSkillsManifest(manifest, fixture) { + assert.equal(manifest.manifestVersion, "v1"); + assert.equal(manifest.environment, fixture.environment); + assert.equal(manifest.commitId, fixture.skillCommitId); + assert.equal(manifest.namespace, "hwlab"); + assert.equal(manifest.endpoint, "http://74.48.78.17:6667"); + assert.equal(manifest.profiles.dev.enabled, true); + assert.equal(manifest.profiles.dev.namespace, "hwlab-dev"); + assert.equal(manifest.services[0].serviceId, AGENT_SKILLS_SERVICE_ID); + assert.equal(manifest.services[0].image, `hwlab-agent-runtime:${fixture.skillCommitId.slice(0, 7)}`); + assert.equal(manifest.services[0].env.HWLAB_SKILLS_COMMIT_ID, fixture.skillCommitId); + assert.deepEqual(manifest.skills, []); +} + +function expectWorkspaceIsolation(primaryPlan, secondaryPlan, fixture) { + const primaryVolumeId = primaryPlan.workspaceVolume.workspaceVolumeId; + const secondaryVolumeId = secondaryPlan.workspaceVolume.workspaceVolumeId; + const secondaryMountPath = `/workspace/${fixture.workspaceIsolation.projectId}/${fixture.workspaceIsolation.agentSessionId}`; + const secondaryEvidenceUri = `file://${path.posix.join(secondaryMountPath, "evidence.txt")}`; + + assert.notEqual(primaryVolumeId, secondaryVolumeId); + assert.notEqual(primaryPlan.workspaceVolume.mountPath, secondaryPlan.workspaceVolume.mountPath); + assert.equal(secondaryPlan.workspaceVolume.mountPath, secondaryMountPath); + assert.equal(secondaryPlan.evidenceRecord.uri, secondaryEvidenceUri); + assert.equal(secondaryPlan.evidenceRecord.metadata.workspaceVolumeId, secondaryVolumeId); + assert.equal(secondaryPlan.traceEvent.traceId, `trc_${fixture.workspaceIsolation.agentSessionId}`); +} + +function expectEvidenceFixture(text, fixture) { + assert.match(text, /^# M4 Agent Automation Loop Evidence Plan/m); + assert.ok(text.includes(`- Scenario: ${fixture.scenario}`)); + assert.ok(text.includes(`- Environment: ${fixture.environment}`)); + assert.ok(text.includes(`- Project: ${fixture.projectId}`)); + assert.ok(text.includes(`- Skill commitId: ${fixture.skillCommitId}`)); + assert.ok(text.includes("no secrets")); + assert.ok(text.includes("no live DEV agent run")); +} + +const fixture = await readJSON("fixtures/mvp/m4-agent-loop/runtime.json"); +const evidenceFixture = await readText(fixture.evidencePath); + +assert.equal(fixture.environment, "dev"); +assert.equal(fixture.projectId.startsWith("prj_"), true); +assert.equal(fixture.agentSessionId.startsWith("agt_"), true); +assert.equal(fixture.skillCommitId.length >= 7, true); +assert.equal(fixture.workspaceIsolation.projectId.startsWith("prj_"), true); +assert.equal(fixture.workspaceIsolation.agentSessionId.startsWith("agt_"), true); + +expectLifecycleConstants(); +expectEvidenceFixture(evidenceFixture, fixture); + +const plan = createAgentRunPlan({ + agentSessionId: fixture.agentSessionId, + projectId: fixture.projectId, + goal: fixture.goal, + prompt: fixture.prompt, + skillCommitId: fixture.skillCommitId, + now: fixedNow +}); + +expectRunPlan(plan, fixture); + +const finished = finalizeAgentRun(plan, { now: fixedNow }); + +expectFinishedRun(finished, plan); + +const manifest = createSkillsManifest({ + commitId: fixture.skillCommitId +}); + +expectSkillsManifest(manifest, fixture); + +const isolationPlan = createAgentRunPlan({ + agentSessionId: fixture.workspaceIsolation.agentSessionId, + projectId: fixture.workspaceIsolation.projectId, + goal: fixture.goal, + prompt: fixture.prompt, + skillCommitId: fixture.skillCommitId, + now: fixedNow +}); + +expectWorkspaceIsolation(plan, isolationPlan, fixture); + +console.log(`[m4-smoke] ok ${fixture.scenario}`);