@tea-agent/loop-agent 0.8.0 → 0.10.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -0
- package/CHANGELOG.md +101 -1
- package/README.md +69 -5
- package/dist/application/dag/args.js +13 -16
- package/dist/application/dag/generate-task-dag.js +32 -2
- package/dist/application/dag/run-dag.js +1 -27
- package/dist/application/dag/validate-dag.js +2 -2
- package/dist/application/loop/run-action.js +0 -4
- package/dist/cli/command-definitions.js +7 -11
- package/dist/cli/program.js +9 -21
- package/dist/commands/cursor-prompt.js +42 -82
- package/dist/commands/dag-approve.js +36 -0
- package/dist/commands/dag-reconcile-run.js +118 -0
- package/dist/commands/delegate.js +75 -77
- package/dist/commands/doctor.js +0 -18
- package/dist/commands/init.js +60 -40
- package/dist/commands/instructions.js +7 -10
- package/dist/commands/loop.js +4 -20
- package/dist/executors/config-core.js +0 -51
- package/dist/executors/dag-pi-executor.js +1 -1
- package/dist/executors/dag.js +0 -1
- package/dist/executors/index.js +0 -2
- package/dist/executors/model-routing.js +9 -9
- package/dist/executors/shell-executor.js +75 -9
- package/dist/governance/checks.js +6 -3
- package/dist/governance/manifest-types.js +33 -2
- package/dist/infrastructure/harness/loop-action-store.js +0 -3
- package/dist/records/harvest.js +2 -23
- package/dist/records/one-shot-runs.js +1 -1
- package/dist/shared/artifacts-core.js +24 -5
- package/dist/shared/output-truncation.js +37 -0
- package/dist/shared/package-metadata.js +353 -0
- package/dist/shared/reference-context.js +48 -22
- package/dist/{executors/cursor-executor.js → sidecars/cursor-prompt/executor.js} +2 -42
- package/dist/sidecars/cursor-prompt/index.js +3 -0
- package/dist/sidecars/cursor-prompt/stream.js +121 -0
- package/dist/task/config-types.js +30 -13
- package/dist/task/delegate.js +9 -21
- package/dist/task/runtime.js +2 -3
- package/dist/worker/cli.js +243 -0
- package/dist/worker/closeout/apply.js +73 -0
- package/dist/worker/closeout/preview.js +30 -0
- package/dist/worker/delivery/final-verification.js +194 -0
- package/dist/worker/delivery/git-transaction.js +354 -0
- package/dist/worker/delivery/package.js +502 -0
- package/dist/worker/feature/decision-loader.js +68 -0
- package/dist/worker/feature/discover.js +14 -0
- package/dist/worker/feature/next-action.js +74 -0
- package/dist/worker/feature/reducer.js +133 -0
- package/dist/worker/feature/review.js +502 -0
- package/dist/worker/feature/run.js +365 -0
- package/dist/worker/feature/types.js +1 -0
- package/dist/worker/follow-up/approve.js +270 -0
- package/dist/worker/follow-up/factory.js +234 -0
- package/dist/worker/follow-up/paths.js +25 -0
- package/dist/worker/follow-up/policy.js +26 -0
- package/dist/worker/follow-up/schema.js +93 -0
- package/dist/worker/follow-up/store.js +96 -0
- package/dist/worker/loop-agent/loop-agent-client.js +345 -101
- package/dist/worker/metrics/projector.js +139 -0
- package/dist/worker/observability/read-model.js +282 -15
- package/dist/worker/observe/paths.js +17 -5
- package/dist/worker/observe/routes.js +78 -20
- package/dist/worker/observe/server.js +8 -6
- package/dist/worker/observe/static/app.js +1045 -177
- package/dist/worker/observe/static/index.html +70 -43
- package/dist/worker/observe/static/styles.css +553 -610
- package/dist/worker/pool/run-store.js +14 -2
- package/dist/worker/pool/validation.js +59 -0
- package/dist/worker/preflight.js +49 -1
- package/dist/worker/report/morning-report.js +41 -6
- package/dist/worker/run-task/run-task.js +23 -13
- package/dist/worker/runner/run-ready.js +89 -11
- package/dist/worker/task-spec/schema.js +0 -1
- package/dist/workflows/dag/convergence/controller.js +1 -1
- package/dist/workflows/dag/executor-registry.js +0 -2
- package/dist/workflows/dag/governance-profile.js +10 -0
- package/dist/workflows/dag/init-hybrid.js +601 -26
- package/dist/workflows/dag/lifecycle.js +146 -0
- package/dist/workflows/dag/node-execution.js +64 -7
- package/dist/workflows/dag/prompt.js +16 -0
- package/dist/workflows/dag/report.js +2 -0
- package/dist/workflows/dag/runner.js +176 -119
- package/dist/workflows/dag/scheduler.js +7 -2
- package/dist/workflows/dag/skill-snapshot.js +527 -0
- package/dist/workflows/dag/types.js +45 -9
- package/dist/workflows/dag/validate.js +5 -8
- package/dist/workflows/loop/actions/dag-action.js +0 -2
- package/dist/workflows/loop/actions/shared.js +1 -1
- package/dist/workflows/loop/actions.js +14 -31
- package/dist/workflows/loop/benchmark.js +1 -1
- package/dist/workflows/loop/index.js +1 -1
- package/dist/workflows/loop/policy/auto-policy.js +22 -14
- package/dist/workflows/loop/policy/path-patterns.js +13 -0
- package/docs/README.md +35 -12
- package/docs/agent-dag-recovery-playbook.md +1 -1
- package/docs/architecture/README.md +26 -0
- package/docs/architecture/dag-execution.md +134 -0
- package/docs/architecture/evolution.md +52 -0
- package/docs/architecture/facts-and-state.md +58 -0
- package/docs/architecture/runtime-boundaries.md +41 -15
- package/docs/architecture/system-overview.md +93 -0
- package/docs/architecture/worker-and-feature.md +81 -0
- package/docs/cursor-prompt-sidecar.md +36 -0
- package/docs/decisions/README.md +13 -1
- package/docs/design/README.md +39 -13
- package/docs/development-principles.md +1 -1
- package/docs/exec-plans/active/README.md +2 -2
- package/docs/exec-plans/completed/README.md +21 -0
- package/docs/feature-workflow.md +44 -4
- package/docs/init-surface.manifest.json +63 -1
- package/docs/loop-agent-harness.md +65 -3
- package/docs/progress/README.md +27 -0
- package/docs/reports/README.md +74 -5
- package/docs/skills/README.md +2 -1
- package/docs/skills/vetted-skill-registry.md +2 -1
- package/docs/templates/agent-dag-report.schema.json +4 -2
- package/docs/templates/agent-dag.base.json +0 -5
- package/docs/templates/agent-dag.final-verification.json +0 -5
- package/docs/templates/agent-dag.schema.json +1 -2
- package/docs/templates/agent-dag.supervised-implementation.json +1 -6
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +131 -0
- package/docs/templates/backend-test-dag.json +213 -0
- package/docs/templates/backend-test-dag.retrospect.prompt.md +128 -0
- package/docs/templates/backend-test-dag.review-cases.prompt.md +85 -0
- package/docs/templates/frontend-design-contract.md +33 -0
- package/docs/templates/frontend-task-constraints.md +25 -0
- package/docs/templates/frontend-task-requirement.md +61 -0
- package/docs/templates/harness.schema.json +8 -5
- package/docs/templates/hybrid-dag.json +1 -6
- package/docs/templates/init-evolution-review.md +4 -2
- package/docs/templates/interactive-ui-round2-experiment.md +1 -1
- package/docs/templates/product-line/task.yaml +0 -1
- package/docs/templates/worker-dogfood-evidence.md +28 -0
- package/docs/templates/worker-dogfood-setup.md +20 -0
- package/docs/verification-matrix.md +17 -0
- package/examples/decision-gate-agent-dag.json +87 -33
- package/examples/example-dag.json +0 -5
- package/examples/hybrid-loop-agent-dag.json +0 -5
- package/harness.json +6 -11
- package/package.json +22 -44
- package/scripts/check-product-line-docs.sh +10 -3
- package/scripts/check-task-pool-root.sh +1 -1
- package/skills/agent-worker/SKILL.md +37 -0
- package/skills/agent-worker/references/agent-worker-operator.md +43 -0
- package/skills/frontend-design-review/SKILL.md +59 -0
- package/skills/frontend-design-review/references/review-checklist.md +37 -0
- package/skills/frontend-implementation/SKILL.md +48 -0
- package/skills/frontend-implementation/references/code-standards.md +34 -0
- package/skills/frontend-implementation/references/design-spec.md +46 -0
- package/skills/frontend-implementation/references/node-contracts.md +32 -0
- package/skills/frontend-review/SKILL.md +53 -0
- package/skills/frontend-review/references/review-findings.md +42 -0
- package/skills/frontend-verification/SKILL.md +40 -0
- package/skills/frontend-verification/references/verification-checklist.md +56 -0
- package/skills/grill-me/SKILL.md +10 -0
- package/skills/grill-with-docs/SKILL.md +88 -0
- package/skills/grill-with-docs/adr-format.md +47 -0
- package/skills/grill-with-docs/context-format.md +60 -0
- package/skills/init-capability-evolution/SKILL.md +1 -0
- package/skills/loop-agent/SKILL.md +11 -9
- package/skills/loop-agent/references/command-reference.md +28 -15
- package/skills/loop-agent/references/docs-converge.md +126 -0
- package/skills/loop-agent/references/harness-policy.md +7 -7
- package/skills/loop-agent/references/hybrid-dag.md +13 -15
- package/skills/loop-agent/references/long-running-loop.md +4 -6
- package/skills/loop-agent/references/multi-worktree.md +6 -6
- package/skills/loop-agent/references/orchestrator-and-interventions.md +3 -3
- package/skills/loop-agent/references/pi-subagent-assisted-mode.md +14 -11
- package/skills/loop-agent/references/verification-and-failure-handling.md +8 -3
- package/skills/using-git-worktrees/SKILL.md +215 -0
- package/dist/commands/cursor-worker.js +0 -43
- package/dist/cursor-worker-entry.js +0 -8
- package/dist/executors/cursor-artifacts.js +0 -33
- package/dist/executors/cursor-execution-log.js +0 -81
- package/dist/executors/cursor-executor-artifacts.js +0 -134
- package/dist/executors/cursor-run.js +0 -115
- package/dist/executors/cursor-tool.js +0 -94
- package/dist/executors/cursor-worker-client.js +0 -223
- package/dist/executors/cursor-worker-protocol.js +0 -18
- package/dist/executors/cursor-worker-server.js +0 -54
- package/dist/executors/cursor-worker.js +0 -3
- package/dist/executors/cursor.js +0 -6
- package/dist/executors/dag-cursor-executor.js +0 -87
- package/dist/workflows/loop/actions/cursor-fix.js +0 -191
- package/dist/workflows/loop/policy/cursor-fix-policy.js +0 -31
- package/docs/cursor-executor-usage.md +0 -25
- package/docs/dynamic-workflow-dag-engine-roadmap.md +0 -1749
|
@@ -1,10 +1,55 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { appendFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { appendFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { computeBinarySha256, computePackageFingerprint, findPackageRoot, isJavaScriptScript, readPackageName, readPackageVersion, resolveBinPaths, resolveCmdShim, } from "../../shared/package-metadata.js";
|
|
5
6
|
import { parseCommandJson } from "./parse-json.js";
|
|
6
7
|
export const DEFAULT_WORKER_COMMAND_TIMEOUT_MS = 120_000;
|
|
7
8
|
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
9
|
+
export const LOOP_AGENT_PACKAGE_NAME = "@tea-agent/loop-agent";
|
|
10
|
+
const MAX_BUFFERED_OUTPUT_BYTES = 64 * 1024;
|
|
11
|
+
/**
|
|
12
|
+
* Bind evidence supplied by an orchestration layer to the identity exposed by
|
|
13
|
+
* the client that will actually launch commands. When the client exposes an
|
|
14
|
+
* identity, it is authoritative; observational fields such as reportedVersion
|
|
15
|
+
* must come from the real preflight probe rather than caller-supplied evidence.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveControllerIdentity(client, provided) {
|
|
18
|
+
const actual = client?.getIdentity?.();
|
|
19
|
+
if (actual && provided && !controllerIdentitiesMatch(actual, provided)) {
|
|
20
|
+
throw new Error("controller identity: provided evidence does not match the command client's pinned identity");
|
|
21
|
+
}
|
|
22
|
+
return actual ?? provided;
|
|
23
|
+
}
|
|
24
|
+
export function controllerIdentitiesMatch(left, right) {
|
|
25
|
+
return Boolean(left && right && identityAnchor(left) === identityAnchor(right));
|
|
26
|
+
}
|
|
27
|
+
/** Pure in-memory expectation gate. It performs no command or filesystem write. */
|
|
28
|
+
export function controllerIdentityExpectationFailure(identity, expectation) {
|
|
29
|
+
if (!expectation)
|
|
30
|
+
return undefined;
|
|
31
|
+
if (!identity) {
|
|
32
|
+
return {
|
|
33
|
+
code: "identity-unavailable",
|
|
34
|
+
message: "controller identity expectation was set but no pinned identity is available",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (expectation.expectedVersion
|
|
38
|
+
&& identity.packageVersion !== expectation.expectedVersion) {
|
|
39
|
+
return {
|
|
40
|
+
code: "expected-version-mismatch",
|
|
41
|
+
message: `expected controller version ${expectation.expectedVersion}, got package version ${identity.packageVersion}`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (expectation.expectedFingerprint
|
|
45
|
+
&& identity.packageFingerprint.value !== expectation.expectedFingerprint) {
|
|
46
|
+
return {
|
|
47
|
+
code: "identity-fingerprint-mismatch",
|
|
48
|
+
message: `expected controller fingerprint ${expectation.expectedFingerprint}, got ${identity.packageFingerprint.value}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
8
53
|
export class LoopAgentClient {
|
|
9
54
|
loopAgentBin;
|
|
10
55
|
baseArgs;
|
|
@@ -12,6 +57,10 @@ export class LoopAgentClient {
|
|
|
12
57
|
defaultTimeoutMs;
|
|
13
58
|
heartbeatIntervalMs;
|
|
14
59
|
env;
|
|
60
|
+
resolutionInputs;
|
|
61
|
+
identityRequired;
|
|
62
|
+
_identity;
|
|
63
|
+
_identityResolved = false;
|
|
15
64
|
constructor(options) {
|
|
16
65
|
this.loopAgentBin = options.loopAgentBin;
|
|
17
66
|
this.baseArgs = options.baseArgs ?? [];
|
|
@@ -21,34 +70,155 @@ export class LoopAgentClient {
|
|
|
21
70
|
this.heartbeatIntervalMs =
|
|
22
71
|
options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
23
72
|
this.env = options.env;
|
|
73
|
+
this.resolutionInputs = options.resolutionInputs;
|
|
74
|
+
this.identityRequired = options.resolveIdentity === true;
|
|
75
|
+
if (this.identityRequired)
|
|
76
|
+
this.resolveIdentity();
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Resolve and freeze the controller identity.
|
|
80
|
+
*/
|
|
81
|
+
resolveIdentity() {
|
|
82
|
+
if (this._identityResolved) {
|
|
83
|
+
if (!this._identity) {
|
|
84
|
+
throw new Error("controller identity: required identity resolution did not produce an identity");
|
|
85
|
+
}
|
|
86
|
+
return this._identity;
|
|
87
|
+
}
|
|
88
|
+
const resolvedAt = new Date().toISOString();
|
|
89
|
+
const resolved = resolveBinPaths(this.loopAgentBin, resolvedAt, this.resolutionInputs);
|
|
90
|
+
// Detect npm Windows .cmd shim and resolve to the actual JS script
|
|
91
|
+
let actualEntry = resolved.realEntry;
|
|
92
|
+
const isWin = this.resolutionInputs?.platform === "win32" || process.platform === "win32";
|
|
93
|
+
if (isWin) {
|
|
94
|
+
const lower = actualEntry.toLowerCase();
|
|
95
|
+
if (lower.endsWith(".cmd") || lower.endsWith(".bat")) {
|
|
96
|
+
const resolvedScript = resolveCmdShim(actualEntry);
|
|
97
|
+
if (resolvedScript) {
|
|
98
|
+
actualEntry = resolvedScript;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
throw new Error(`controller identity: cannot resolve npm .cmd shim: ${resolved.entry}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Detect "running from source" pattern: loopAgentBin is a Node
|
|
106
|
+
// executable and baseArgs points to a script. In this case,
|
|
107
|
+
// actualEntry must be the script, not the Node binary.
|
|
108
|
+
const sourceScript = findSourceScript(this.baseArgs, this.resolutionInputs?.cwd ?? process.cwd());
|
|
109
|
+
if (sourceScript) {
|
|
110
|
+
actualEntry = sourceScript.path;
|
|
111
|
+
}
|
|
112
|
+
// Validate the entry file is readable
|
|
113
|
+
try {
|
|
114
|
+
const s = statSync(actualEntry);
|
|
115
|
+
if (!s.isFile())
|
|
116
|
+
throw new Error(`not a file: ${actualEntry}`);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw new Error(`controller identity: bin entry is not readable: ${actualEntry}`);
|
|
120
|
+
}
|
|
121
|
+
const packageRoot = findPackageRoot(path.dirname(actualEntry));
|
|
122
|
+
if (!packageRoot) {
|
|
123
|
+
throw new Error(`controller identity: cannot locate package root for ${actualEntry}`);
|
|
124
|
+
}
|
|
125
|
+
const packageName = readPackageName(packageRoot);
|
|
126
|
+
if (!packageName) {
|
|
127
|
+
throw new Error(`controller identity: package.json at ${packageRoot} is missing a "name" field`);
|
|
128
|
+
}
|
|
129
|
+
if (packageName !== LOOP_AGENT_PACKAGE_NAME) {
|
|
130
|
+
throw new Error(`controller identity: unexpected package name ${JSON.stringify(packageName)} at ${packageRoot}; expected ${JSON.stringify(LOOP_AGENT_PACKAGE_NAME)}`);
|
|
131
|
+
}
|
|
132
|
+
const pkgVersion = readPackageVersion(packageRoot);
|
|
133
|
+
if (!pkgVersion) {
|
|
134
|
+
throw new Error(`controller identity: package.json at ${packageRoot} is missing a "version" field`);
|
|
135
|
+
}
|
|
136
|
+
const binarySha256 = computeBinarySha256(actualEntry);
|
|
137
|
+
if (!binarySha256) {
|
|
138
|
+
throw new Error(`controller identity: cannot read binary for hashing: ${actualEntry}`);
|
|
139
|
+
}
|
|
140
|
+
// Determine launch command and argsPrefix
|
|
141
|
+
let launchCommand;
|
|
142
|
+
let launchArgsPrefix;
|
|
143
|
+
if (sourceScript) {
|
|
144
|
+
launchCommand = resolveNodeBinary(this.loopAgentBin, resolvedAt, this.resolutionInputs);
|
|
145
|
+
launchArgsPrefix = [...this.baseArgs];
|
|
146
|
+
launchArgsPrefix[sourceScript.index] = sourceScript.path;
|
|
147
|
+
}
|
|
148
|
+
else if (isJavaScriptScript(actualEntry)) {
|
|
149
|
+
launchCommand = frozenNodeExecutable();
|
|
150
|
+
launchArgsPrefix = [actualEntry, ...this.baseArgs];
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
launchCommand = actualEntry;
|
|
154
|
+
launchArgsPrefix = [...this.baseArgs];
|
|
155
|
+
}
|
|
156
|
+
const identity = {
|
|
157
|
+
schemaVersion: 1,
|
|
158
|
+
packageName,
|
|
159
|
+
binName: path.basename(actualEntry),
|
|
160
|
+
requested: this.loopAgentBin,
|
|
161
|
+
entry: resolved.entry,
|
|
162
|
+
realEntry: actualEntry,
|
|
163
|
+
launch: { command: launchCommand, argsPrefix: launchArgsPrefix },
|
|
164
|
+
binarySha256,
|
|
165
|
+
packageRoot,
|
|
166
|
+
packageVersion: pkgVersion,
|
|
167
|
+
packageFingerprint: computePackageFingerprint(packageRoot),
|
|
168
|
+
resolvedAt,
|
|
169
|
+
};
|
|
170
|
+
this._identity = identity;
|
|
171
|
+
this._identityResolved = true;
|
|
172
|
+
return identity;
|
|
173
|
+
}
|
|
174
|
+
getIdentity() {
|
|
175
|
+
return this._identity;
|
|
176
|
+
}
|
|
177
|
+
observeReportedVersion(reportedVersion) {
|
|
178
|
+
if (!this._identity) {
|
|
179
|
+
throw new Error("controller identity: cannot record a reported version without a pinned identity");
|
|
180
|
+
}
|
|
181
|
+
if (reportedVersion !== this._identity.packageVersion) {
|
|
182
|
+
throw new Error(`controller identity: reported version ${reportedVersion} does not match package version ${this._identity.packageVersion}`);
|
|
183
|
+
}
|
|
184
|
+
this._identity = { ...this._identity, reportedVersion };
|
|
185
|
+
return this._identity;
|
|
24
186
|
}
|
|
25
187
|
async run(args, options) {
|
|
188
|
+
if (this._identity) {
|
|
189
|
+
assertControllerIdentityUnchangedBeforeSpawn(this._identity);
|
|
190
|
+
const launch = this._identity.launch;
|
|
191
|
+
const fullArgs = [...launch.argsPrefix, ...args];
|
|
192
|
+
return this.runCommand(launch.command, fullArgs, args, options, () => assertControllerIdentityUnchangedBeforeSpawn(this._identity));
|
|
193
|
+
}
|
|
194
|
+
if (this.identityRequired || this._identityResolved) {
|
|
195
|
+
throw new Error("controller identity: pinned launch is unavailable");
|
|
196
|
+
}
|
|
26
197
|
return this.runCommand(this.loopAgentBin, [...this.baseArgs, ...args], args, options);
|
|
27
198
|
}
|
|
28
199
|
async runExternal(command, args, options) {
|
|
29
200
|
return this.runCommand(command, args, args, options);
|
|
30
201
|
}
|
|
31
|
-
async runCommand(command, commandArgs, resultArgs, options) {
|
|
202
|
+
async runCommand(command, commandArgs, resultArgs, options, beforeSpawn) {
|
|
32
203
|
const startedAt = Date.now();
|
|
33
204
|
const artifactDir = path.join(this.artifactRoot, sanitizeName(options.artifactName));
|
|
34
205
|
await mkdir(artifactDir, { recursive: true });
|
|
35
206
|
const stdoutPath = path.join(artifactDir, "stdout.txt");
|
|
36
207
|
const stderrPath = path.join(artifactDir, "stderr.txt");
|
|
37
208
|
const resultPath = path.join(artifactDir, "result.json");
|
|
38
|
-
const { stdout, stderr, exitCode, timedOut } = await spawnCommand({
|
|
209
|
+
const { stdout, stdoutBytes, stdoutTruncated, stderr, stderrBytes, stderrTruncated, exitCode, timedOut, } = await spawnCommand({
|
|
39
210
|
command,
|
|
40
211
|
args: commandArgs,
|
|
41
212
|
cwd: options.cwd,
|
|
42
213
|
timeoutMs: options.timeoutMs ?? this.defaultTimeoutMs,
|
|
43
214
|
env: { ...process.env, ...this.env, ...options.env },
|
|
44
|
-
stdoutPath,
|
|
45
|
-
stderrPath,
|
|
46
|
-
resultPath,
|
|
215
|
+
stdoutPath, stderrPath, resultPath,
|
|
47
216
|
heartbeatIntervalMs: this.heartbeatIntervalMs,
|
|
48
217
|
onSpawn: options.onSpawn,
|
|
49
218
|
onStdout: options.onStdout,
|
|
50
219
|
onStderr: options.onStderr,
|
|
51
220
|
onHeartbeat: options.onHeartbeat,
|
|
221
|
+
beforeSpawn,
|
|
52
222
|
});
|
|
53
223
|
const result = {
|
|
54
224
|
ok: exitCode === 0 && !timedOut,
|
|
@@ -57,18 +227,16 @@ export class LoopAgentClient {
|
|
|
57
227
|
cwd: options.cwd,
|
|
58
228
|
durationMs: Date.now() - startedAt,
|
|
59
229
|
exitCode,
|
|
60
|
-
stdout,
|
|
61
|
-
stderr,
|
|
230
|
+
stdout, stdoutBytes, stdoutTruncated,
|
|
231
|
+
stderr, stderrBytes, stderrTruncated,
|
|
62
232
|
timedOut,
|
|
63
|
-
artifacts: {
|
|
64
|
-
dir: artifactDir,
|
|
65
|
-
stdoutPath,
|
|
66
|
-
stderrPath,
|
|
67
|
-
resultPath,
|
|
68
|
-
},
|
|
233
|
+
artifacts: { dir: artifactDir, stdoutPath, stderrPath, resultPath },
|
|
69
234
|
};
|
|
70
235
|
if (options.expectJson) {
|
|
71
|
-
const
|
|
236
|
+
const jsonSource = stdoutTruncated
|
|
237
|
+
? await readFullOutputArtifact(stdoutPath, stdout)
|
|
238
|
+
: stdout;
|
|
239
|
+
const parsed = parseCommandJson(jsonSource);
|
|
72
240
|
if (parsed.ok) {
|
|
73
241
|
result.json = parsed.value;
|
|
74
242
|
}
|
|
@@ -77,16 +245,108 @@ export class LoopAgentClient {
|
|
|
77
245
|
result.parseFailure = parsed.failure;
|
|
78
246
|
}
|
|
79
247
|
}
|
|
80
|
-
await writeFile(result.artifacts.stdoutPath, stdout, "utf-8");
|
|
81
|
-
await writeFile(result.artifacts.stderrPath, stderr, "utf-8");
|
|
82
248
|
await writeFile(result.artifacts.resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8");
|
|
83
249
|
return result;
|
|
84
250
|
}
|
|
85
251
|
}
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Internal helpers
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
function assertControllerIdentityUnchangedBeforeSpawn(identity) {
|
|
256
|
+
const currentPackageRoot = findPackageRoot(path.dirname(identity.realEntry));
|
|
257
|
+
if (currentPackageRoot !== identity.packageRoot) {
|
|
258
|
+
throw controllerIdentityDriftError(`package root changed from ${JSON.stringify(identity.packageRoot)} to ${JSON.stringify(currentPackageRoot)}`);
|
|
259
|
+
}
|
|
260
|
+
const currentPackageName = readPackageName(identity.packageRoot);
|
|
261
|
+
if (currentPackageName !== identity.packageName) {
|
|
262
|
+
throw controllerIdentityDriftError(`package name changed from ${JSON.stringify(identity.packageName)} to ${JSON.stringify(currentPackageName)}`);
|
|
263
|
+
}
|
|
264
|
+
const currentPackageVersion = readPackageVersion(identity.packageRoot);
|
|
265
|
+
if (currentPackageVersion !== identity.packageVersion) {
|
|
266
|
+
throw controllerIdentityDriftError(`package version changed from ${JSON.stringify(identity.packageVersion)} to ${JSON.stringify(currentPackageVersion)}`);
|
|
267
|
+
}
|
|
268
|
+
const currentBinarySha256 = computeBinarySha256(identity.realEntry);
|
|
269
|
+
if (currentBinarySha256 !== identity.binarySha256) {
|
|
270
|
+
throw controllerIdentityDriftError(`entry binary sha256 changed from ${identity.binarySha256} to ${currentBinarySha256 ?? "unreadable"}`);
|
|
271
|
+
}
|
|
272
|
+
let currentPackageFingerprint;
|
|
273
|
+
try {
|
|
274
|
+
currentPackageFingerprint = computePackageFingerprint(identity.packageRoot);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
throw controllerIdentityDriftError(`package fingerprint cannot be recomputed: ${error instanceof Error ? error.message : String(error)}`, error);
|
|
278
|
+
}
|
|
279
|
+
if (currentPackageFingerprint.algorithm !== identity.packageFingerprint.algorithm
|
|
280
|
+
|| currentPackageFingerprint.scopeVersion !== identity.packageFingerprint.scopeVersion
|
|
281
|
+
|| JSON.stringify(currentPackageFingerprint.scope)
|
|
282
|
+
!== JSON.stringify(identity.packageFingerprint.scope)
|
|
283
|
+
|| currentPackageFingerprint.value !== identity.packageFingerprint.value
|
|
284
|
+
|| currentPackageFingerprint.fileCount !== identity.packageFingerprint.fileCount) {
|
|
285
|
+
throw controllerIdentityDriftError(`package fingerprint changed from ${identity.packageFingerprint.value} to ${currentPackageFingerprint.value}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function controllerIdentityDriftError(message, cause) {
|
|
289
|
+
return new Error(`controller identity changed before spawn: ${message}`, {
|
|
290
|
+
cause,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Detect whether loopAgentBin is a Node-like executable and baseArgs
|
|
295
|
+
* contains a script file. Returns the absolute script path, or undefined.
|
|
296
|
+
*/
|
|
297
|
+
function findSourceScript(baseArgs, cwd) {
|
|
298
|
+
if (baseArgs.length === 0)
|
|
299
|
+
return undefined;
|
|
300
|
+
// Look for the last .ts/.js/.mjs/.cjs file in baseArgs
|
|
301
|
+
let scriptIdx = -1;
|
|
302
|
+
for (let i = baseArgs.length - 1; i >= 0; i--) {
|
|
303
|
+
const ext = path.extname(baseArgs[i]).toLowerCase();
|
|
304
|
+
if (ext === ".ts" || ext === ".js" || ext === ".mjs" || ext === ".cjs") {
|
|
305
|
+
scriptIdx = i;
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (scriptIdx < 0)
|
|
310
|
+
return undefined;
|
|
311
|
+
const scriptPath = path.resolve(cwd, baseArgs[scriptIdx]);
|
|
312
|
+
try {
|
|
313
|
+
const s = statSync(scriptPath);
|
|
314
|
+
if (s.isFile())
|
|
315
|
+
return { index: scriptIdx, path: realpathSync(scriptPath) };
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// script not found
|
|
319
|
+
}
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Resolve the Node binary to an absolute path.
|
|
324
|
+
*/
|
|
325
|
+
function resolveNodeBinary(loopAgentBin, resolvedAt, inputs) {
|
|
326
|
+
if (path.isAbsolute(loopAgentBin))
|
|
327
|
+
return realpathSync(loopAgentBin);
|
|
328
|
+
const resolved = resolveBinPaths(loopAgentBin, resolvedAt, inputs);
|
|
329
|
+
return resolved.realEntry;
|
|
330
|
+
}
|
|
331
|
+
function frozenNodeExecutable() {
|
|
332
|
+
try {
|
|
333
|
+
return realpathSync(process.execPath);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return process.execPath;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
86
339
|
function spawnCommand(input) {
|
|
87
340
|
return new Promise((resolve, reject) => {
|
|
88
341
|
const startedAtMs = Date.now();
|
|
89
342
|
const startedAt = new Date(startedAtMs).toISOString();
|
|
343
|
+
try {
|
|
344
|
+
input.beforeSpawn?.();
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
reject(error);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
90
350
|
const child = spawn(input.command, input.args, {
|
|
91
351
|
cwd: input.cwd,
|
|
92
352
|
env: input.env,
|
|
@@ -97,101 +357,85 @@ function spawnCommand(input) {
|
|
|
97
357
|
writeFileSync(input.stdoutPath, "", "utf-8");
|
|
98
358
|
writeFileSync(input.stderrPath, "", "utf-8");
|
|
99
359
|
}
|
|
100
|
-
catch {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
pid: child.pid,
|
|
105
|
-
startedAt,
|
|
106
|
-
artifactRefs: {
|
|
107
|
-
stdoutPath: input.stdoutPath,
|
|
108
|
-
stderrPath: input.stderrPath,
|
|
109
|
-
resultPath: input.resultPath,
|
|
110
|
-
},
|
|
111
|
-
});
|
|
112
|
-
let stdout = "";
|
|
113
|
-
let stderr = "";
|
|
360
|
+
catch { /* best-effort */ }
|
|
361
|
+
invokeSpawnCallback(input.onSpawn, { pid: child.pid, startedAt, artifactRefs: { stdoutPath: input.stdoutPath, stderrPath: input.stderrPath, resultPath: input.resultPath } });
|
|
362
|
+
let stdout = createBoundedOutput();
|
|
363
|
+
let stderr = createBoundedOutput();
|
|
114
364
|
let timedOut = false;
|
|
115
|
-
const timeout = setTimeout(() => {
|
|
116
|
-
timedOut = true;
|
|
117
|
-
child.kill("SIGTERM");
|
|
118
|
-
}, input.timeoutMs);
|
|
365
|
+
const timeout = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, input.timeoutMs);
|
|
119
366
|
let heartbeatTimer;
|
|
120
367
|
if (input.heartbeatIntervalMs > 0) {
|
|
121
|
-
heartbeatTimer = setInterval(() => {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (heartbeatTimer !== undefined) {
|
|
128
|
-
clearInterval(heartbeatTimer);
|
|
129
|
-
heartbeatTimer = undefined;
|
|
130
|
-
}
|
|
131
|
-
};
|
|
368
|
+
heartbeatTimer = setInterval(() => { invokeHeartbeatCallback(input.onHeartbeat, startedAtMs); }, input.heartbeatIntervalMs);
|
|
369
|
+
}
|
|
370
|
+
const clearTimers = () => { clearTimeout(timeout); if (heartbeatTimer !== undefined) {
|
|
371
|
+
clearInterval(heartbeatTimer);
|
|
372
|
+
heartbeatTimer = undefined;
|
|
373
|
+
} };
|
|
132
374
|
child.stdout.setEncoding("utf8");
|
|
133
375
|
child.stderr.setEncoding("utf8");
|
|
134
|
-
child.stdout.on("data", (chunk) => {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
invokeChunkCallback(input.onStdout, chunk);
|
|
138
|
-
});
|
|
139
|
-
child.stderr.on("data", (chunk) => {
|
|
140
|
-
stderr += chunk;
|
|
141
|
-
appendChunkBestEffort(input.stderrPath, chunk);
|
|
142
|
-
invokeChunkCallback(input.onStderr, chunk);
|
|
143
|
-
});
|
|
144
|
-
child.on("error", (error) => {
|
|
145
|
-
clearTimers();
|
|
146
|
-
reject(error);
|
|
147
|
-
});
|
|
376
|
+
child.stdout.on("data", (chunk) => { stdout = appendBoundedOutput(stdout, chunk); appendChunkBestEffort(input.stdoutPath, chunk); invokeChunkCallback(input.onStdout, chunk); });
|
|
377
|
+
child.stderr.on("data", (chunk) => { stderr = appendBoundedOutput(stderr, chunk); appendChunkBestEffort(input.stderrPath, chunk); invokeChunkCallback(input.onStderr, chunk); });
|
|
378
|
+
child.on("error", (error) => { clearTimers(); reject(error); });
|
|
148
379
|
child.on("close", (exitCode) => {
|
|
149
380
|
clearTimers();
|
|
150
|
-
resolve({ stdout, stderr, exitCode, timedOut });
|
|
381
|
+
resolve({ stdout: formatBoundedOutput(stdout), stdoutBytes: stdout.bytes, stdoutTruncated: stdout.truncated, stderr: formatBoundedOutput(stderr), stderrBytes: stderr.bytes, stderrTruncated: stderr.truncated, exitCode, timedOut });
|
|
151
382
|
});
|
|
152
383
|
});
|
|
153
384
|
}
|
|
154
|
-
function
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
385
|
+
function createBoundedOutput() { return { bytes: 0, text: "", truncated: false }; }
|
|
386
|
+
function appendBoundedOutput(current, chunk) {
|
|
387
|
+
const bytes = current.bytes + Buffer.byteLength(chunk);
|
|
388
|
+
const combined = current.text + chunk;
|
|
389
|
+
if (Buffer.byteLength(combined) <= MAX_BUFFERED_OUTPUT_BYTES)
|
|
390
|
+
return { bytes, text: combined, truncated: current.truncated };
|
|
391
|
+
const tail = Buffer.from(combined).subarray(-MAX_BUFFERED_OUTPUT_BYTES).toString("utf-8").replace(/^\uFFFD/, "");
|
|
392
|
+
return { bytes, text: tail, truncated: true };
|
|
161
393
|
}
|
|
162
|
-
function
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
try {
|
|
167
|
-
callback(info);
|
|
168
|
-
}
|
|
169
|
-
catch (error) {
|
|
170
|
-
process.stderr.write(`LoopAgentClient onSpawn callback error: ${String(error)}\n`);
|
|
171
|
-
}
|
|
394
|
+
function formatBoundedOutput(output) { return output.truncated ? `${output.text}\n...[truncated]` : output.text; }
|
|
395
|
+
async function readFullOutputArtifact(filePath, fallback) { try {
|
|
396
|
+
return await readFile(filePath, "utf-8");
|
|
172
397
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
callback(chunk);
|
|
179
|
-
}
|
|
180
|
-
catch (error) {
|
|
181
|
-
process.stderr.write(`LoopAgentClient output callback error: ${String(error)}\n`);
|
|
182
|
-
}
|
|
398
|
+
catch {
|
|
399
|
+
return fallback;
|
|
400
|
+
} }
|
|
401
|
+
function appendChunkBestEffort(filePath, chunk) { try {
|
|
402
|
+
appendFileSync(filePath, chunk, "utf-8");
|
|
183
403
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
try {
|
|
189
|
-
callback({ at: new Date().toISOString(), elapsedMs: Date.now() - startedAtMs });
|
|
190
|
-
}
|
|
191
|
-
catch (error) {
|
|
192
|
-
process.stderr.write(`LoopAgentClient onHeartbeat callback error: ${String(error)}\n`);
|
|
193
|
-
}
|
|
404
|
+
catch { /* best-effort */ } }
|
|
405
|
+
function invokeSpawnCallback(cb, info) { if (!cb)
|
|
406
|
+
return; try {
|
|
407
|
+
cb(info);
|
|
194
408
|
}
|
|
195
|
-
|
|
196
|
-
|
|
409
|
+
catch (error) {
|
|
410
|
+
process.stderr.write(`LoopAgentClient onSpawn callback error: ${String(error)}\n`);
|
|
411
|
+
} }
|
|
412
|
+
function invokeChunkCallback(cb, chunk) { if (!cb)
|
|
413
|
+
return; try {
|
|
414
|
+
cb(chunk);
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
process.stderr.write(`LoopAgentClient output callback error: ${String(error)}\n`);
|
|
418
|
+
} }
|
|
419
|
+
function invokeHeartbeatCallback(cb, startedAtMs) { if (!cb)
|
|
420
|
+
return; try {
|
|
421
|
+
cb({ at: new Date().toISOString(), elapsedMs: Date.now() - startedAtMs });
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
process.stderr.write(`LoopAgentClient onHeartbeat callback error: ${String(error)}\n`);
|
|
425
|
+
} }
|
|
426
|
+
function sanitizeName(name) { return name.replace(/[^a-zA-Z0-9._-]+/g, "-"); }
|
|
427
|
+
function identityAnchor(identity) {
|
|
428
|
+
return JSON.stringify({
|
|
429
|
+
schemaVersion: identity.schemaVersion,
|
|
430
|
+
packageName: identity.packageName,
|
|
431
|
+
binName: identity.binName,
|
|
432
|
+
requested: identity.requested,
|
|
433
|
+
entry: identity.entry,
|
|
434
|
+
realEntry: identity.realEntry,
|
|
435
|
+
launch: identity.launch,
|
|
436
|
+
binarySha256: identity.binarySha256,
|
|
437
|
+
packageRoot: identity.packageRoot,
|
|
438
|
+
packageVersion: identity.packageVersion,
|
|
439
|
+
packageFingerprint: identity.packageFingerprint,
|
|
440
|
+
});
|
|
197
441
|
}
|