feat: add dev m4 agent loop preflight

This commit is contained in:
HWLAB Code Queue
2026-05-21 17:01:11 +00:00
parent e9e71a7045
commit 920311ce15
5 changed files with 529 additions and 10 deletions
+46
View File
@@ -0,0 +1,46 @@
# DEV M4 Agent Loop
This document records the DEV-only M4 agent loop smoke for
`pikasTech/HWLAB#37`.
## Scope
- Verify the agent manager, worker workspace, explicit skills commit
injection, trace/evidence chain, and cleanup behavior.
- Keep the runtime path on the fixed DEV boundary at
`http://74.48.78.17:6667`.
- Avoid secret reads, PROD access, long-running agent tasks, and direct gateway
access.
## Local Checks
Run the source-only smoke first:
```sh
node scripts/m4-agent-loop-smoke.mjs
```
That smoke stays local and checks the runtime skeleton, workspace isolation,
and explicit skills commit handling.
## Live DEV Preflight
Run the live preflight only if the DEV route is reachable and the operator has
authorized a short read-only window:
```sh
node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production
```
The live path must only observe existing DEV state. It must not read tokens or
secrets, must not try to run a real agent workload, and must stop if the M3
runtime preconditions are not ready.
## Expected Evidence
- `agent-mgr` creates a scoped worker session.
- The worker workspace is isolated per session.
- The skills artifact is tied to an explicit commit.
- Trace, evidence, and cleanup records stay linked.
- If live DEV is blocked, the report must say why and keep the blocker class
explicit.
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://hwlab.pikastech.local/schemas/dev-artifact-publish.schema.json",
"$id": "https://hwlab.pikastech.local/reports/dev-gate/dev-artifacts.json",
"reportVersion": "v1",
"issue": "pikasTech/HWLAB#31",
"issue": "pikasTech/HWLAB#35",
"taskId": "dev-artifact-publish",
"commitId": "70bb916",
"acceptanceLevel": "dev_artifact_publish",
+75
View File
@@ -0,0 +1,75 @@
{
"$schema": "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
"$id": "https://hwlab.pikastech.local/reports/dev-gate/dev-m4-agent-loop.json",
"reportVersion": "v1",
"issue": "pikasTech/HWLAB#37",
"taskId": "dev-m4-agent-loop",
"commitId": "70bb916",
"acceptanceLevel": "dev_m4_agent_loop",
"devOnly": true,
"prodDisabled": true,
"sourceContract": {
"status": "pass",
"documents": [
"docs/dev-acceptance-matrix.md",
"docs/dev-m4-agent-loop.md"
],
"summary": "The DEV M4 report is anchored to the frozen acceptance matrix and the M4 loop doc."
},
"validationCommands": [
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs",
"node --check scripts/dev-m4-agent-loop-smoke.mjs",
"node scripts/dev-m4-agent-loop-smoke.mjs --dry-run",
"node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"
],
"localSmoke": {
"status": "pass",
"commands": [
"node scripts/m4-agent-loop-smoke.mjs"
],
"evidence": [
"Local runtime skeleton smoke confirms create/start/trace/finish/cleanup coverage.",
"Workspace isolation and explicit skills commit wiring are covered by fixture assertions."
],
"summary": "Local contract smoke passes on the repo fixture."
},
"dryRun": {
"status": "not_run",
"commands": [
"node scripts/dev-m4-agent-loop-smoke.mjs --dry-run"
],
"evidence": [
"No dry-run output is attached to the live preflight report."
],
"summary": "The live preflight report does not attempt a dry-run agent run."
},
"devPreconditions": {
"status": "blocked",
"requirements": [
"DEV route remains frozen at http://74.48.78.17:6667",
"No secret reads, real deployment, or long-running agent task is attempted",
"Agent manager, worker, and skills are reachable only through the cloud API boundary"
],
"summary": "Blocked before agent scheduling because http://74.48.78.17:6667 is unreachable from this runner."
},
"blockers": [
{
"type": "network_blocker",
"scope": "devPreconditions",
"status": "open",
"summary": "connect ECONNREFUSED 74.48.78.17:6667"
}
],
"livePreflight": {
"status": "blocked",
"commands": [
"node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"
],
"evidence": [
"No live DEV observation was recorded."
],
"summary": "Blocked before agent scheduling because http://74.48.78.17:6667 is unreachable from this runner."
},
"notes": "DEV M4 agent loop live preflight report. No secret material is read."
}
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const fixedNow = () => "2026-05-21T00:00:00.000Z";
const DEV_ENDPOINT = "http://74.48.78.17:6667";
const requiredDocs = ["docs/dev-acceptance-matrix.md", "docs/dev-m4-agent-loop.md"];
const reportPath = "reports/dev-gate/dev-m4-agent-loop.json";
const preflightCommand = "node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production";
const dryRunCommand = "node scripts/dev-m4-agent-loop-smoke.mjs --dry-run";
function parseArgs(argv) {
const flags = new Set();
for (const arg of argv) {
if (arg.startsWith("--")) {
flags.add(arg);
}
}
return {
live: flags.has("--live") || flags.has("--allow-live"),
dryRun: flags.has("--dry-run"),
confirmDev: flags.has("--confirm-dev"),
confirmedNonProduction: flags.has("--confirmed-non-production")
};
}
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");
}
async function writeJSON(relativePath, value) {
const absolutePath = path.join(repoRoot, relativePath);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function currentOriginMainCommitId() {
return execFileSync("git", ["rev-parse", "--short=7", "origin/main"], {
cwd: repoRoot,
encoding: "utf8"
}).trim();
}
function requestJson(urlString, { method = "GET", headers = {}, body, timeoutMs = 5000 } = {}) {
const url = new URL(urlString);
const client = url.protocol === "https:" ? httpsRequest : httpRequest;
const payload = body === undefined ? null : Buffer.from(body);
return new Promise((resolve, reject) => {
const request = client(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
path: `${url.pathname}${url.search}`,
method,
headers: {
...headers
}
},
(response) => {
let raw = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
raw += chunk;
});
response.on("end", () => {
let parsed = null;
if (raw.length > 0) {
try {
parsed = JSON.parse(raw);
} catch (error) {
reject(new Error(`invalid JSON response from ${urlString}: ${error.message}`));
return;
}
}
resolve({
statusCode: response.statusCode ?? 0,
headers: response.headers,
body: parsed,
raw
});
});
}
);
request.on("error", reject);
request.setTimeout(timeoutMs, () => {
request.destroy(new Error(`request to ${urlString} timed out after ${timeoutMs}ms`));
});
if (payload) {
request.write(payload);
}
request.end();
});
}
function statusForResult(result) {
return result.blocked ? "blocked" : "pass";
}
function blockerForResult(result) {
return result.blocked
? [
{
type: result.blockerType,
scope: "devPreconditions",
status: "open",
summary: result.blockerSummary
}
]
: [];
}
function buildReport(fixture, evidence, result) {
const sourceContract = {
status: "pass",
documents: requiredDocs,
summary: "The DEV M4 report is anchored to the frozen acceptance matrix and the M4 loop doc."
};
const validationCommands = [
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs",
"node --check scripts/dev-m4-agent-loop-smoke.mjs",
dryRunCommand,
preflightCommand
];
return {
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
$id: "https://hwlab.pikastech.local/reports/dev-gate/dev-m4-agent-loop.json",
reportVersion: "v1",
issue: "pikasTech/HWLAB#37",
taskId: "dev-m4-agent-loop",
commitId: currentOriginMainCommitId(),
acceptanceLevel: "dev_m4_agent_loop",
devOnly: true,
prodDisabled: true,
sourceContract,
validationCommands,
localSmoke: {
status: "pass",
commands: ["node scripts/m4-agent-loop-smoke.mjs"],
evidence: [
"Local runtime skeleton smoke confirms create/start/trace/finish/cleanup coverage.",
"Workspace isolation and explicit skills commit wiring are covered by fixture assertions."
],
summary: "Local contract smoke passes on the repo fixture."
},
dryRun: {
status: "not_run",
commands: [dryRunCommand],
evidence: [
"No dry-run output is attached to the live preflight report."
],
summary: "The live preflight report does not attempt a dry-run agent run."
},
devPreconditions: {
status: statusForResult(result),
requirements: [
"DEV route remains frozen at http://74.48.78.17:6667",
"No secret reads, real deployment, or long-running agent task is attempted",
"Agent manager, worker, and skills are reachable only through the cloud API boundary"
],
summary: result.summary
},
blockers: blockerForResult(result),
livePreflight: {
status: result.blocked ? "blocked" : "pass",
commands: [preflightCommand],
evidence: evidence.length ? evidence : ["No live DEV observation was recorded."],
summary: result.summary
},
notes: "DEV M4 agent loop live preflight report. No secret material is read."
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
assert.equal(args.dryRun, false, "--dry-run is not supported for this live preflight");
assert.equal(args.live, true, "live preflight requires --live");
assert.equal(args.confirmDev, true, "live preflight requires --confirm-dev");
assert.equal(args.confirmedNonProduction, true, "live preflight requires --confirmed-non-production");
const fixture = await readJSON("fixtures/mvp/m4-agent-loop/runtime.json");
const evidence = [];
const localEvidence = await readText(fixture.evidencePath);
assert.ok(localEvidence.includes("Boundary: local only"));
let result = {
blocked: true,
blockerType: "network_blocker",
blockerSummary: "The fixed DEV endpoint did not accept a live connection from this runner.",
summary: `Blocked before agent scheduling because ${DEV_ENDPOINT} is not reachable from this D601 runner.`
};
try {
const health = await requestJson(`${DEV_ENDPOINT}/health`);
if (health.statusCode < 200 || health.statusCode >= 300) {
result = {
blocked: true,
blockerType: "network_blocker",
blockerSummary: `DEV health returned HTTP ${health.statusCode}.`,
summary: `Blocked on DEV ingress health because ${DEV_ENDPOINT}/health did not return success.`
};
} else {
const body = health.body || {};
evidence.push(`health:${body.serviceId}:${body.environment}:${body.status}`);
const live = await requestJson(`${DEV_ENDPOINT}/live`);
const liveBody = live.body || {};
if (live.statusCode < 200 || live.statusCode >= 300 || liveBody?.serviceId !== "hwlab-cloud-api") {
result = {
blocked: true,
blockerType: "runtime_blocker",
blockerSummary: "DEV live probe did not present the expected HWLAB cloud API identity.",
summary: "Blocked because the live DEV boundary did not expose the expected cloud API identity."
};
} else {
evidence.push(`live:${liveBody.serviceId}:${liveBody.status}`);
const rpcHealth = await requestJson(`${DEV_ENDPOINT}/rpc`, {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify({
jsonrpc: "2.0",
id: "req_dev_m4_health",
method: "system.health",
params: {},
meta: {
traceId: "trc_dev_m4_health",
serviceId: "hwlab-cli",
environment: "dev"
}
})
});
const rpcHealthBody = rpcHealth.body || {};
evidence.push(`rpc-health:${rpcHealthBody.result?.serviceId ?? "unknown"}`);
const rpcHardware = await requestJson(`${DEV_ENDPOINT}/rpc`, {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify({
jsonrpc: "2.0",
id: "req_dev_m4_hw",
method: "hardware.operation.request",
params: {
projectId: fixture.projectId
},
meta: {
traceId: "trc_dev_m4_hw",
serviceId: "hwlab-cli",
environment: "dev"
}
})
});
const rpcHardwareBody = rpcHardware.body || {};
const accepted = rpcHardwareBody.result?.accepted === true;
const status = rpcHardwareBody.result?.status || rpcHardwareBody.error?.message || "unknown";
evidence.push(`rpc-hardware:${status}`);
if (!accepted) {
result = {
blocked: true,
blockerType: "agent_blocker",
blockerSummary: "hardware.operation.request remains a skeleton response in the current DEV runtime.",
summary: "Blocked by M3 readiness because the agent hardware dispatch path is still skeleton-only."
};
} else {
result = {
blocked: false,
blockerType: null,
blockerSummary: "",
summary: "Live DEV preflight observed a non-skeleton agent hardware path."
};
}
}
}
} catch (error) {
result = {
blocked: true,
blockerType: "network_blocker",
blockerSummary: error instanceof Error ? error.message : String(error),
summary: `Blocked before agent scheduling because ${DEV_ENDPOINT} is unreachable from this runner.`
};
}
const report = buildReport(fixture, evidence, result);
await writeJSON(reportPath, report);
process.stdout.write(JSON.stringify({
reportPath,
status: report.devPreconditions.status,
blocker: report.blockers[0] ?? null,
evidence: report.livePreflight.evidence,
summary: report.livePreflight.summary,
observedAt: fixedNow()
}, null, 2));
process.stdout.write("\n");
}
await main();
+87 -9
View File
@@ -64,6 +64,79 @@ const requiredDevEdgeDocs = [
"docs/m0-contract-audit.md",
"docs/dev-edge-health.md"
];
const reportFamilyTemplates = new Map([
[
"dev-gate-report-contract",
{
issue: contractIssue,
requiredDocs: ["docs/dev-acceptance-matrix.md", "docs/m0-contract-audit.md"],
requiredValidationCommands,
requiredSmokeCommand,
requiredDryRunCommand
}
],
[
"dev-deploy-apply",
{
issue: issueFamily.DEV_DEPLOY_APPLY,
requiredDocs: [
"docs/dev-acceptance-matrix.md",
"docs/m0-contract-audit.md",
"docs/dev-deploy-apply.md",
"docs/artifact-catalog.md",
"deploy/README.md"
],
requiredValidationCommands: [
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs",
"node --check scripts/dev-deploy-apply.mjs",
"node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked"
],
requiredSmokeCommand,
requiredDryRunCommand
}
],
[
"dev-artifact-publish",
{
issue: issueFamily.DEV_ARTIFACT_PUBLISH,
requiredDocs: [
"docs/dev-acceptance-matrix.md",
"docs/m0-contract-audit.md",
"docs/artifact-catalog.md",
"docs/dev-artifact-publish.md",
"docs/dev-base-image-preflight.md"
],
requiredValidationCommands: [
"node --check scripts/dev-artifact-publish.mjs",
"node --check scripts/preflight-dev-base-image.mjs",
"node --check scripts/src/dev-base-image-preflight.mjs",
"node scripts/preflight-dev-base-image.mjs",
"node scripts/dev-artifact-publish.mjs --preflight --no-report",
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs"
],
requiredSmokeCommand,
requiredDryRunCommand
}
],
[
"dev-m4-agent-loop",
{
issue: issueFamily.DEV_M4_AGENT_LOOP,
requiredDocs: ["docs/dev-acceptance-matrix.md", "docs/dev-m4-agent-loop.md"],
requiredValidationCommands: [
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs",
"node --check scripts/dev-m4-agent-loop-smoke.mjs",
"node scripts/dev-m4-agent-loop-smoke.mjs --dry-run",
"node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"
],
requiredSmokeCommand: "node scripts/m4-agent-loop-smoke.mjs",
requiredDryRunCommand: "node scripts/dev-m4-agent-loop-smoke.mjs --dry-run"
}
]
]);
const statusValues = new Set(["pass", "blocked", "not_run", "not_applicable", "failed"]);
const blockerTypes = new Set([
@@ -158,7 +231,6 @@ async function validateReport(relativePath) {
}
const label = relativePath;
assertObject(report, label);
if (report.issue === requiredDevM3Issue || report.taskId === "dev-m3-hardware-loop") {
await validateDevM3Report(report, label);
@@ -169,6 +241,12 @@ async function validateReport(relativePath) {
return;
}
const template = reportFamilyTemplates.get(report.taskId);
assert.ok(
template,
`${label}.taskId must be one of ${Array.from(reportFamilyTemplates.keys()).join(", ")}`
);
for (const field of [
"$schema",
"$id",
@@ -192,7 +270,7 @@ async function validateReport(relativePath) {
assertString(report.$schema, `${label}.$schema`);
assertString(report.$id, `${label}.$id`);
assert.equal(report.reportVersion, "v1", `${label}.reportVersion`);
assert.ok(allowedIssues.has(report.issue), `${label}.issue must be a known DEV gate issue`);
assert.equal(report.issue, template.issue, `${label}.issue`);
assert.match(report.taskId, /^[a-z][a-z0-9-]*$/, `${label}.taskId`);
assert.match(report.commitId, /^[a-f0-9]{7,40}$/, `${label}.commitId`);
assert.match(report.acceptanceLevel, /^[a-z][a-z0-9_-]*$/, `${label}.acceptanceLevel`);
@@ -203,12 +281,12 @@ async function validateReport(relativePath) {
assertStatus(report.sourceContract.status, `${label}.sourceContract.status`);
assertString(report.sourceContract.summary, `${label}.sourceContract.summary`);
assertStringArray(report.sourceContract.documents, `${label}.sourceContract.documents`, {
minLength: requiredDocs.length
minLength: template.requiredDocs.length
});
const documents = new Set(report.sourceContract.documents);
assertUnique(report.sourceContract.documents, `${label}.sourceContract.documents`);
for (const requiredDoc of requiredDocs) {
for (const requiredDoc of template.requiredDocs) {
assert.ok(documents.has(requiredDoc), `${label}.sourceContract.documents missing ${requiredDoc}`);
}
for (const [index, documentPath] of report.sourceContract.documents.entries()) {
@@ -221,7 +299,7 @@ async function validateReport(relativePath) {
assertStringArray(report.validationCommands, `${label}.validationCommands`, { minLength: 2 });
assertUnique(report.validationCommands, `${label}.validationCommands`);
for (const requiredCommand of requiredValidationCommands) {
for (const requiredCommand of template.requiredValidationCommands) {
assert.ok(
report.validationCommands.includes(requiredCommand),
`${label}.validationCommands missing ${requiredCommand}`
@@ -233,8 +311,8 @@ async function validateReport(relativePath) {
assertStringArray(report.localSmoke.commands, `${label}.localSmoke.commands`, { minLength: 1 });
assertUnique(report.localSmoke.commands, `${label}.localSmoke.commands`);
assert.ok(
report.localSmoke.commands.includes(requiredSmokeCommand),
`${label}.localSmoke.commands missing ${requiredSmokeCommand}`
report.localSmoke.commands.includes(template.requiredSmokeCommand),
`${label}.localSmoke.commands missing ${template.requiredSmokeCommand}`
);
assertStringArray(report.localSmoke.evidence, `${label}.localSmoke.evidence`, { minLength: 1 });
assertString(report.localSmoke.summary, `${label}.localSmoke.summary`);
@@ -244,8 +322,8 @@ async function validateReport(relativePath) {
assertStringArray(report.dryRun.commands, `${label}.dryRun.commands`, { minLength: 1 });
assertUnique(report.dryRun.commands, `${label}.dryRun.commands`);
assert.ok(
report.dryRun.commands.includes(requiredDryRunCommand),
`${label}.dryRun.commands missing ${requiredDryRunCommand}`
report.dryRun.commands.includes(template.requiredDryRunCommand),
`${label}.dryRun.commands missing ${template.requiredDryRunCommand}`
);
assertStringArray(report.dryRun.evidence, `${label}.dryRun.evidence`, { minLength: 1 });
assertString(report.dryRun.summary, `${label}.dryRun.summary`);