@kylecheng3146/agent-ops 0.11.3 → 0.11.5
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/README.md +8 -1
- package/dist/packages/cli/src/args.js +18 -3
- package/dist/packages/cli/src/cli.js +5 -0
- package/dist/packages/cli/src/commands/advance.js +2 -1
- package/dist/packages/cli/src/commands/init.js +2 -5
- package/dist/packages/cli/src/commands/review.js +1 -0
- package/dist/packages/cli/src/commands/run.js +9 -1
- package/dist/packages/cli/src/run-deps.js +18 -2
- package/dist/runtime/src/install/doctor.js +3 -5
- package/dist/runtime/src/install/harness.js +2 -1
- package/dist/runtime/src/install/plan.js +1 -1
- package/dist/runtime/src/parallel/finish.js +7 -4
- package/dist/runtime/src/parallel/integrate.js +6 -4
- package/dist/runtime/src/review/runner.js +6 -1
- package/dist/runtime/src/run/land.js +145 -0
- package/dist/runtime/src/run/service.js +34 -0
- package/dist/runtime/src/surface/discovery.js +18 -7
- package/docs/en/spec/acceptance-and-evidence.md +8 -3
- package/docs/en/spec/harness-adapters.md +2 -1
- package/docs/en/spec/loop-engineering.md +13 -0
- package/docs/en/spec/review.md +3 -1
- package/docs/zh-TW/spec/acceptance-and-evidence.md +6 -4
- package/docs/zh-TW/spec/harness-adapters.md +2 -2
- package/docs/zh-TW/spec/loop-engineering.md +1 -0
- package/docs/zh-TW/spec/review.md +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,8 +53,14 @@ agent-ops run logs <run-id>
|
|
|
53
53
|
agent-ops run respond <run-id> --question-id <id> --answer "Clarification"
|
|
54
54
|
agent-ops run stop <run-id>
|
|
55
55
|
agent-ops run resume <run-id>
|
|
56
|
+
agent-ops run land <run-id> [--task <task-id>]... --yes
|
|
56
57
|
```
|
|
57
58
|
|
|
59
|
+
`run land` merges the delivered children of a paused, blocked or budget-limited
|
|
60
|
+
run ahead of the rest: it verifies them, runs the two-round review and finishes
|
|
61
|
+
with a receipt in a separate landing worktree, while the run stays resumable and
|
|
62
|
+
its final proof re-verifies the whole goal.
|
|
63
|
+
|
|
58
64
|
`--host claude` uses Claude Code; `--wait` waits for a terminal or input state.
|
|
59
65
|
The coordinator counts toward the two writer slots. Native processes write only
|
|
60
66
|
under current leases in separate worktrees. Active time counts once across parallel
|
|
@@ -268,7 +274,8 @@ question through `agent-ops run respond`. A start refused with
|
|
|
268
274
|
`WORKTREE_NESTED` falls back to the subtask flow; `RUN_REPO_UNTRUSTED` stops and
|
|
269
275
|
asks you. A run that ends without completing is reported with its status, last
|
|
270
276
|
log events, and the `agent-ops run resume <id>` / `agent-ops run stop <id>`
|
|
271
|
-
commands
|
|
277
|
+
commands (plus `agent-ops run land <id> --yes` when it has delivered tasks); it
|
|
278
|
+
is never resumed or landed automatically. agy keeps the subtask flow.
|
|
272
279
|
Init and update pre-authorize `agent-ops run` for Claude Code and Codex.
|
|
273
280
|
|
|
274
281
|
```bash
|
|
@@ -922,7 +922,7 @@ export function parseArgs(argv) {
|
|
|
922
922
|
};
|
|
923
923
|
}
|
|
924
924
|
function parseRunArgs(argv) {
|
|
925
|
-
const actions = ["start", "status", "logs", "resume", "stop", "respond"];
|
|
925
|
+
const actions = ["start", "status", "logs", "resume", "stop", "respond", "land"];
|
|
926
926
|
if (argv.includes("--help") || argv.includes("-h"))
|
|
927
927
|
return { command: "help", helpTopic: "run", profiles: [], dryRun: false, json: false, yes: false, rerun: false };
|
|
928
928
|
const action = actions.includes(argv[0]) ? argv[0] : "start";
|
|
@@ -937,21 +937,30 @@ function parseRunArgs(argv) {
|
|
|
937
937
|
positional = token;
|
|
938
938
|
continue;
|
|
939
939
|
}
|
|
940
|
-
if (seen.has(token) && token !== "--retry-approval")
|
|
940
|
+
if (seen.has(token) && token !== "--retry-approval" && token !== "--task")
|
|
941
941
|
duplicate(token);
|
|
942
942
|
seen.add(token);
|
|
943
943
|
if (token === "--json") {
|
|
944
944
|
args.json = true;
|
|
945
945
|
continue;
|
|
946
946
|
}
|
|
947
|
+
if (token === "--yes") {
|
|
948
|
+
args.yes = true;
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
947
951
|
if (token === "--wait") {
|
|
948
952
|
args.wait = true;
|
|
949
953
|
continue;
|
|
950
954
|
}
|
|
951
|
-
if (!["--host", "--goal-file", "--time-budget", "--jobs", "--question-id", "--answer", "--answer-file", "--retry-approval", "--retry-delivery"].includes(token))
|
|
955
|
+
if (!["--host", "--goal-file", "--time-budget", "--jobs", "--question-id", "--answer", "--answer-file", "--retry-approval", "--retry-delivery", "--task"].includes(token))
|
|
952
956
|
throw new CliArgumentError("CLI_UNKNOWN_OPTION", `Unknown run option: ${token}`, token);
|
|
953
957
|
const value = readOptionValue(argv, i++, token);
|
|
954
958
|
switch (token) {
|
|
959
|
+
case "--task":
|
|
960
|
+
if (!/^[A-Za-z0-9._:/-]{1,256}$/u.test(value) || args.landTaskIds?.includes(value))
|
|
961
|
+
invalidValue(token, value);
|
|
962
|
+
args.landTaskIds = [...(args.landTaskIds ?? []), value];
|
|
963
|
+
break;
|
|
955
964
|
case "--retry-delivery":
|
|
956
965
|
if (!/^task-[a-z0-9-]{1,128}$/u.test(value))
|
|
957
966
|
invalidValue(token, value);
|
|
@@ -991,6 +1000,12 @@ function parseRunArgs(argv) {
|
|
|
991
1000
|
}
|
|
992
1001
|
if (args.retryApprovalIds !== undefined && action !== "resume")
|
|
993
1002
|
throw new CliArgumentError("CLI_INVALID_OPTION", "--retry-approval requires run resume.");
|
|
1003
|
+
if (args.landTaskIds !== undefined && action !== "land")
|
|
1004
|
+
throw new CliArgumentError("CLI_INVALID_OPTION", "--task requires run land.");
|
|
1005
|
+
if (args.yes !== (action === "land"))
|
|
1006
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", action === "land"
|
|
1007
|
+
? "Run land requires --yes to authorize its verification and both reviewer sessions."
|
|
1008
|
+
: "--yes is accepted only by run land.");
|
|
994
1009
|
if (args.retryDeliveryTaskId !== undefined && (action !== "resume" || args.retryApprovalIds !== undefined))
|
|
995
1010
|
throw new CliArgumentError("CLI_INVALID_OPTION", "--retry-delivery requires run resume without --retry-approval.");
|
|
996
1011
|
if (action === "start") {
|
|
@@ -130,6 +130,7 @@ The URL carries an access token; do not share it.
|
|
|
130
130
|
agent-ops run resume <run-id> [--retry-approval <auth-event-id>]... [--json]
|
|
131
131
|
agent-ops run resume <run-id> --retry-delivery <task-id> [--json]
|
|
132
132
|
agent-ops run respond <run-id> --question-id <id> <--answer <text>|--answer-file <path>> [--json]
|
|
133
|
+
agent-ops run land <run-id> [--task <task-id>]... --yes [--json]
|
|
133
134
|
|
|
134
135
|
New runs have no total time cap. Manual resume upgrades legacy finite runs and keeps accounting.
|
|
135
136
|
Command/review timeouts, provider limits and stop controls remain effective.
|
|
@@ -144,6 +145,10 @@ Status includes phase, original cause, recorded sealed proof, cleanup state,
|
|
|
144
145
|
resumability and remediation. Resume revalidates authority and sealed artifacts;
|
|
145
146
|
receipt-bound recovery executes only missing cleanup steps. Resolve the saved
|
|
146
147
|
blocker before retrying: unchanged cleanup inputs are refused.
|
|
148
|
+
land merges delivered children of a paused, blocked or budget-limited run ahead
|
|
149
|
+
of the rest: it verifies them, runs the two-round review and finishes with a
|
|
150
|
+
receipt, leaving the run resumable. --task picks a subset; each one's
|
|
151
|
+
dependencies must be picked too or already landed.
|
|
147
152
|
`,
|
|
148
153
|
init: `Usage: agent-ops init [options]
|
|
149
154
|
|
|
@@ -102,7 +102,7 @@ export async function runAdvanceCommand(options) {
|
|
|
102
102
|
if (parent.status !== "active" || parent.task.parentTaskId !== undefined) {
|
|
103
103
|
throw new AgentOpsError("ADVANCE_PARENT_INVALID", "Advance requires an active root task in the coordinator worktree.");
|
|
104
104
|
}
|
|
105
|
-
const children = await integrateSessionChildren(deps, record, parent.task.id);
|
|
105
|
+
const children = await integrateSessionChildren(deps, record, parent.task.id, undefined, options.children);
|
|
106
106
|
const step = options.step ?? runAdvanceStep;
|
|
107
107
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
108
108
|
const target = await git(deps, mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`]);
|
|
@@ -159,6 +159,7 @@ export async function runAdvanceCommand(options) {
|
|
|
159
159
|
...(noChangePaths === undefined ? {} : { noChangePaths }) });
|
|
160
160
|
const sourceFingerprint = await calculateSourceFingerprint(record.path, scope, runner(deps, record.path));
|
|
161
161
|
const finalProof = { target, head, sourceFingerprint, children,
|
|
162
|
+
...(options.children === undefined ? {} : { keepChildren: true }),
|
|
162
163
|
...(noChangePaths === undefined ? {} : { noChange: { sourceCommit: head,
|
|
163
164
|
contractDigest: await deps.tasks(record.path).treeContract(parent.task.id), reviewScope: scope,
|
|
164
165
|
artifactRefs: Object.values((await deps.tasks(record.path).status({ taskId: parent.task.id })).evidence).flat() } }) };
|
|
@@ -127,13 +127,10 @@ export async function runInitCommand(options) {
|
|
|
127
127
|
: { worktree: worktreeConfig })
|
|
128
128
|
});
|
|
129
129
|
const trust = await trustChange(options, plan);
|
|
130
|
-
//
|
|
131
|
-
// completion needs current PASS evidence from a required verifier, so the
|
|
132
|
-
// loop can never close. Detection stays conservative on purpose — it will
|
|
133
|
-
// not guess a test command — which makes saying so out loud the whole fix.
|
|
130
|
+
// Empty commands block legacy criteria, not typed acceptance backed by runners.
|
|
134
131
|
const verificationWarnings = plan.config.verification.commands.length === 0
|
|
135
132
|
? [
|
|
136
|
-
"No verification command is configured
|
|
133
|
+
"No verification command is configured; legacy criteria need a required verifier. " +
|
|
137
134
|
"Add verification.commands to .agent-ops/config.json." +
|
|
138
135
|
(plan.verificationBlockers.length === 0
|
|
139
136
|
? ""
|
|
@@ -317,6 +317,7 @@ const SURFACE_REASONS = {
|
|
|
317
317
|
SURFACE_UNCLASSIFIED: "surface-unclassified",
|
|
318
318
|
SURFACE_QUERY_TOO_BROAD: "surface-query-too-broad",
|
|
319
319
|
SURFACE_EXCLUSION_TOO_BROAD: "surface-exclusion-too-broad",
|
|
320
|
+
SURFACE_INCLUSION_TOO_BROAD: "surface-inclusion-too-broad",
|
|
320
321
|
SCENARIO_MISSING: "scenario-missing",
|
|
321
322
|
SURFACE_BUDGET_EXCEEDED: "surface-budget-exceeded",
|
|
322
323
|
SURFACE_UNASSIGNED: "surface-unassigned",
|
|
@@ -9,7 +9,7 @@ function actionOf(args) {
|
|
|
9
9
|
const value = dynamic(args).runAction ?? dynamic(args).action;
|
|
10
10
|
if (value === undefined || value === "start")
|
|
11
11
|
return "start";
|
|
12
|
-
if (["status", "logs", "resume", "stop", "respond"].includes(String(value)))
|
|
12
|
+
if (["status", "logs", "resume", "stop", "respond", "land"].includes(String(value)))
|
|
13
13
|
return value;
|
|
14
14
|
throw new AgentOpsError("RUN_ACTION_INVALID", `Unsupported run action: ${String(value)}`);
|
|
15
15
|
}
|
|
@@ -109,6 +109,14 @@ export async function runRunCommand(options) {
|
|
|
109
109
|
}
|
|
110
110
|
if (action === "resume")
|
|
111
111
|
return okEnvelope("RUN_RESUMED", data(action, await options.service.resume(runId, options.args.retryApprovalIds, true, options.args.retryDeliveryTaskId)));
|
|
112
|
+
if (action === "land") {
|
|
113
|
+
if (options.service.land === undefined)
|
|
114
|
+
throw new AgentOpsError("RUN_LAND_UNSUPPORTED", "This CLI cannot land run deliveries.");
|
|
115
|
+
const landed = await options.service.land(runId, options.args.landTaskIds);
|
|
116
|
+
const message = `Landed ${landed.taskIds.join(", ")} from run ${runId}. Receipt: ${landed.receipt}.\n` +
|
|
117
|
+
`The run stays ${landed.state.status}; continue the rest with: agent-ops run resume ${runId}`;
|
|
118
|
+
return okEnvelope("RUN_LANDED", { ...data(action, { state: landed.state, message }), landedTaskIds: landed.taskIds, receipt: landed.receipt });
|
|
119
|
+
}
|
|
112
120
|
if (action === "stop")
|
|
113
121
|
return okEnvelope("RUN_STOP_REQUESTED", data(action, await options.service.stop(runId, options.args.answer ?? "user requested stop")));
|
|
114
122
|
const questionId = options.args.questionId;
|
|
@@ -9,7 +9,8 @@ import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
|
|
|
9
9
|
import { sha256 } from "../../../runtime/src/fs/hash.js";
|
|
10
10
|
import { resolveCheckouts, readWorktreeRecord, sessionWorktreeName } from "../../../runtime/src/parallel/service.js";
|
|
11
11
|
import { createLaunchdDescriptor, LaunchdController, readBootIdentity, readGuiLoginIdentity } from "../../../runtime/src/run/macOS.js";
|
|
12
|
-
import { FileRunRepository, RunService, validateDeliveryRetry } from "../../../runtime/src/run/service.js";
|
|
12
|
+
import { FileRunRepository, RunService, landingMarkerLive, validateDeliveryRetry } from "../../../runtime/src/run/service.js";
|
|
13
|
+
import { landRun } from "../../../runtime/src/run/land.js";
|
|
13
14
|
import { readPrivateFile, writePrivateFile } from "../../../runtime/src/security/permissions.js";
|
|
14
15
|
import { collectChangeSurface } from "../../../runtime/src/verify/change-surface.js";
|
|
15
16
|
import { worktreeDependencies, gitRunner, trustStore } from "./parallel-deps.js";
|
|
@@ -28,6 +29,7 @@ import { nativeProcessIdentity } from "../../../runtime/src/run/transport.js";
|
|
|
28
29
|
import { redactSecrets } from "../../../runtime/src/security/redact.js";
|
|
29
30
|
import { RunControlService } from "../../../runtime/src/run/controls.js";
|
|
30
31
|
import { recordRunPhase } from "../../../runtime/src/run/phase.js";
|
|
32
|
+
import { runAdvanceCommand } from "./commands/advance.js";
|
|
31
33
|
/** Worktree construction happens before a native lease exists; it must never write global trust. */
|
|
32
34
|
export async function runWorktreeDependencies(deps, state) {
|
|
33
35
|
state = await ensureRunPolicy(new FileRunRepository(join(state.commonDir, "agent-ops/runs"), state.commonDir), state);
|
|
@@ -213,6 +215,8 @@ export async function productionRunContext(cwd, options = {}) {
|
|
|
213
215
|
};
|
|
214
216
|
const resume = async (runId, retryApprovalIds = [], upgradeBudget = true, retryDeliveryTaskId) => {
|
|
215
217
|
const before = await service.status(runId);
|
|
218
|
+
if (landingMarkerLive(before.landing))
|
|
219
|
+
throw new AgentOpsError("RUN_LANDING_IN_PROGRESS", `Run ${runId} is landing delivered work; wait for run land to finish.`);
|
|
216
220
|
const retry = retryDeliveryTaskId === undefined ? null : validateDeliveryRetry(before, retryDeliveryTaskId);
|
|
217
221
|
const runtimeHash = await runRuntimeHash();
|
|
218
222
|
const policy = await readRunPolicy(before, before.policyBinding?.runtimeHash ?? runtimeHash, Date.now(), true);
|
|
@@ -315,6 +319,18 @@ export async function productionRunContext(cwd, options = {}) {
|
|
|
315
319
|
}
|
|
316
320
|
return { state, message: `Run ${runId} stopped; registered native process death was confirmed.` };
|
|
317
321
|
};
|
|
322
|
+
const land = async (runId, taskIds) => {
|
|
323
|
+
if (checkouts.currentRoot !== checkouts.mainRoot)
|
|
324
|
+
throw new AgentOpsError("WORKTREE_NESTED", "Land a run from the main checkout.");
|
|
325
|
+
return await landRun({ repository, runId, ...(taskIds === undefined ? {} : { taskIds }), deps,
|
|
326
|
+
advance: async (input) => {
|
|
327
|
+
const result = await runAdvanceCommand({ ...input, deps });
|
|
328
|
+
const receipt = result.data?.receipt;
|
|
329
|
+
if (result.status !== "ok" || typeof receipt !== "string")
|
|
330
|
+
throw new AgentOpsError(result.code, "Landing proof did not finish with a receipt.");
|
|
331
|
+
return { receipt };
|
|
332
|
+
} });
|
|
333
|
+
};
|
|
318
334
|
const respond = async (runId, questionId, answer) => {
|
|
319
335
|
const state = await service.status(runId);
|
|
320
336
|
if (!state.questions.some(q => q.questionId === questionId && q.answeredAt === null))
|
|
@@ -331,7 +347,7 @@ export async function productionRunContext(cwd, options = {}) {
|
|
|
331
347
|
}
|
|
332
348
|
};
|
|
333
349
|
return { ...checkouts, targetBranch: target.stdout.trim(), ownerSessionId: randomUUID(), repository,
|
|
334
|
-
service: { start, resume, stop, respond, status: service.status.bind(service), logs: service.logs.bind(service) },
|
|
350
|
+
service: { start, resume, stop, respond, land, status: service.status.bind(service), logs: service.logs.bind(service) },
|
|
335
351
|
waitForCompletion: async (runId) => {
|
|
336
352
|
for (;;) {
|
|
337
353
|
const state = await service.status(runId);
|
|
@@ -380,17 +380,15 @@ async function checkRegistrationDrift(root, manifest, config) {
|
|
|
380
380
|
}
|
|
381
381
|
}
|
|
382
382
|
/**
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
* configuration — it is a loop that cannot close, and an installation reaches
|
|
386
|
-
* that state quietly whenever stack detection declines to guess.
|
|
383
|
+
* Legacy criteria need required verifiers; typed acceptance can use runners
|
|
384
|
+
* even when no ordinary verification command is configured.
|
|
387
385
|
*/
|
|
388
386
|
function checkVerificationCommands(config) {
|
|
389
387
|
if (config === undefined) {
|
|
390
388
|
return check("verification-commands", "UNKNOWN", "Configuration could not be read, so verifiers are unknown.");
|
|
391
389
|
}
|
|
392
390
|
if (config.verification.commands.length === 0) {
|
|
393
|
-
return check("verification-commands", "DEGRADED", "No verification command is configured
|
|
391
|
+
return check("verification-commands", "DEGRADED", "No verification command is configured; legacy criteria need a required verifier.",
|
|
394
392
|
// Codeless on purpose: the fix is an edit to the configuration file, not
|
|
395
393
|
// an agent-ops command, and a code here would force a non-zero exit on
|
|
396
394
|
// every installation whose stack detection declined to guess.
|
|
@@ -347,7 +347,8 @@ function autoRunInstruction(descriptor, context) {
|
|
|
347
347
|
"report the `agent-ops run status",
|
|
348
348
|
"<id>` state and code, the last `agent-ops run logs <id>` events, and the",
|
|
349
349
|
"exact `agent-ops run resume <id>` and `agent-ops run stop <id>` commands,",
|
|
350
|
-
"
|
|
350
|
+
"plus `agent-ops run land <id> --yes` when it has delivered tasks,",
|
|
351
|
+
"then stop: never resume or land automatically or take over the run's work.",
|
|
351
352
|
""
|
|
352
353
|
];
|
|
353
354
|
}
|
|
@@ -88,7 +88,7 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
|
|
|
88
88
|
const verification = existing?.verification !== undefined &&
|
|
89
89
|
existing.verification.commands.length > 0
|
|
90
90
|
? existing.verification
|
|
91
|
-
: { commands: [...detectedCommands] };
|
|
91
|
+
: { ...existing?.verification, commands: [...detectedCommands] };
|
|
92
92
|
// Pre-0.11 DEBUG budget settings are dropped on rewrite.
|
|
93
93
|
const existingFeatures = existing === undefined ? undefined : (({ debug: _legacyDebug, ...rest }) => rest)(existing.features);
|
|
94
94
|
const effectiveWorktree = worktree !== undefined
|
|
@@ -318,9 +318,12 @@ export async function finishWorktree(deps, options) {
|
|
|
318
318
|
return await withActivity(commonDir, { kind: "finish", root: record.path, sessionId: record.sessionId, worktree: name }, async () => await withFinishLock(deps, commonDir, async () => {
|
|
319
319
|
const listing = await git(deps, mainRoot, ["worktree", "list", "--porcelain"], "WORKTREE_LIST_FAILED", "Cannot list cleanup worktrees.");
|
|
320
320
|
const records = await Promise.all(parseWorktreeListPorcelain(listing).map(child => readWorktreeRecord(child.path)));
|
|
321
|
+
const keepChildren = options.finalProof?.keepChildren === true;
|
|
321
322
|
const sessionChildren = records
|
|
322
|
-
.filter((child) => child !== null && child.path !== record.path &&
|
|
323
|
-
|
|
323
|
+
.filter((child) => child !== null && child.path !== record.path && (keepChildren
|
|
324
|
+
? options.finalProof.children.some(({ name }) => name === child.name)
|
|
325
|
+
: child.sessionId === record.sessionId &&
|
|
326
|
+
(child.agentId !== undefined || (record.runId !== undefined && child.runId === record.runId))));
|
|
324
327
|
if (options.finalProof?.recovery === true && record.runId !== undefined) {
|
|
325
328
|
const saved = await new FileRunRepository(join(commonDir, "agent-ops/runs"), commonDir).read(record.runId);
|
|
326
329
|
if (["receipt-written", "cleaned"].includes(saved?.integration?.status ?? ""))
|
|
@@ -579,7 +582,7 @@ export async function finishWorktree(deps, options) {
|
|
|
579
582
|
// this session began would leave it reading the merge as its own change.
|
|
580
583
|
// Only this worktree leaves the session; its other roots stay covered.
|
|
581
584
|
await mainGate.afterFinish(record.sessionId, record.path, after);
|
|
582
|
-
if (options.finalProof !== undefined) {
|
|
585
|
+
if (options.finalProof !== undefined && !keepChildren) {
|
|
583
586
|
for (const child of sessionChildren) {
|
|
584
587
|
await mainGate.afterFinish(record.sessionId, child.path, after);
|
|
585
588
|
}
|
|
@@ -593,7 +596,7 @@ export async function finishWorktree(deps, options) {
|
|
|
593
596
|
await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
|
|
594
597
|
if (receiptOptions !== undefined)
|
|
595
598
|
await markRunIntegration(commonDir, record, "receipt-written", "trust");
|
|
596
|
-
if (options.finalProof !== undefined) {
|
|
599
|
+
if (options.finalProof !== undefined && !keepChildren) {
|
|
597
600
|
for (const child of sessionChildren) {
|
|
598
601
|
await attempt(`child ${child.name}`, async () => {
|
|
599
602
|
const pinnedHead = await assertPinnedChild(child);
|
|
@@ -156,11 +156,13 @@ async function previous(record) {
|
|
|
156
156
|
return value;
|
|
157
157
|
}
|
|
158
158
|
/** Integrate only this session's agent-owned branches; local proof is never promoted as final proof. */
|
|
159
|
-
export async function integrateSessionChildren(deps, coordinator, parentTaskId, eligibleRunWorkerIds
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
export async function integrateSessionChildren(deps, coordinator, parentTaskId, eligibleRunWorkerIds,
|
|
160
|
+
/** Another session's children chosen by the caller (`run land`); replaces the session filter only. */
|
|
161
|
+
explicitChildren) {
|
|
162
|
+
const statuses = explicitChildren === undefined ? await listWorktrees(deps, coordinator.mainRoot) : [];
|
|
163
|
+
const children = (explicitChildren ?? statuses.map(({ record }) => record)
|
|
162
164
|
.filter((record) => record.name !== coordinator.name && record.sessionId === coordinator.sessionId &&
|
|
163
|
-
(record.agentId !== undefined || (coordinator.runId !== undefined && record.runId === coordinator.runId)))
|
|
165
|
+
(record.agentId !== undefined || (coordinator.runId !== undefined && record.runId === coordinator.runId))))
|
|
164
166
|
.filter(record => eligibleRunWorkerIds === undefined || (record.workerId !== undefined && eligibleRunWorkerIds.includes(record.workerId)))
|
|
165
167
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
166
168
|
const integrated = await previous(coordinator);
|
|
@@ -108,6 +108,8 @@ function taskDataBlock(invocation) {
|
|
|
108
108
|
function surfaceBlock(invocation, round) {
|
|
109
109
|
if (invocation.packet.surface === undefined)
|
|
110
110
|
return [];
|
|
111
|
+
const discovery = invocation.packet.surface.discovery;
|
|
112
|
+
const skipped = round === "adversarial" ? discovery?.skipped ?? [] : [];
|
|
111
113
|
return [
|
|
112
114
|
"",
|
|
113
115
|
"Surface coverage (narrow exception to \"Do not survey\"): task data carries the surface audit, its revision " +
|
|
@@ -121,7 +123,10 @@ function surfaceBlock(invocation, round) {
|
|
|
121
123
|
"and indirect consumer. Do not run other searches beyond callers a specific question needs."
|
|
122
124
|
: "Round 2: check the five fixed scenarios (initialState, redo, timeBoundary, identitySwitch, concurrency) against " +
|
|
123
125
|
"the code, and hunt unlisted surfaces with at most 3 new `git grep -F` queries per axis (dependents, duplicates, " +
|
|
124
|
-
"otherPaths, sharedState); cite each query you ran in evidence."
|
|
126
|
+
"otherPaths, sharedState); cite each query you ran in evidence.",
|
|
127
|
+
...(skipped.length === 0 ? [] : ["Skipped derived queries (over 10 files outside the diff, so discovery never gated them; " +
|
|
128
|
+
`fewest first): ${skipped.map(row => `\`${row.query}\` (${row.files})`).join(", ")}. Beyond the per-axis budget, run at most 3 ` +
|
|
129
|
+
"extra `git grep -F` queries in total over these to find copied siblings the change missed; cite each in evidence."])
|
|
125
130
|
];
|
|
126
131
|
}
|
|
127
132
|
/** Escape Loop: both rounds adjudicate the earliest-gate label of each declared escape. */
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { calculateConfigHash } from "../config/hash.js";
|
|
5
|
+
import { sha256 } from "../fs/hash.js";
|
|
6
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
7
|
+
import { listWorktrees } from "../parallel/manage.js";
|
|
8
|
+
import { addWorktree, readWorktreeRecord, removeCheckout, sessionWorktreeName } from "../parallel/service.js";
|
|
9
|
+
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
10
|
+
import { landingMarkerLive, MAX_EVENTS } from "./service.js";
|
|
11
|
+
const LIVE_WORKER = ["assigned", "starting", "running", "idle", "handing-off"];
|
|
12
|
+
const LANDABLE = ["paused", "blocked", "budget-limited"];
|
|
13
|
+
/** Inside the landing worktree; names the run that owns it so another run never reuses it. */
|
|
14
|
+
const OWNER_FILE = join(".agent-ops", "tasks", "run-landing.json");
|
|
15
|
+
export function assertLandable(state) {
|
|
16
|
+
if (!LANDABLE.includes(state.status) || state.rootTaskId === null ||
|
|
17
|
+
state.workers.some(worker => LIVE_WORKER.includes(worker.status)))
|
|
18
|
+
throw new AgentOpsError("RUN_LAND_STATE_INVALID", `Run ${state.runId} is ${state.status}; land needs a paused, blocked or budget-limited run with a root task and no live worker.`);
|
|
19
|
+
}
|
|
20
|
+
/** Unlanded delivered children, or the requested ones; every dependency must be selected or already landed. */
|
|
21
|
+
export function selectLanding(state, requested) {
|
|
22
|
+
const landed = new Set((state.landings ?? []).flatMap(landing => landing.taskIds));
|
|
23
|
+
const eligible = state.tasks.filter(task => task.taskId !== state.rootTaskId && task.status === "delivered" &&
|
|
24
|
+
task.sourceCommit !== null && !landed.has(task.taskId));
|
|
25
|
+
for (const id of requested ?? [])
|
|
26
|
+
if (!eligible.some(task => task.taskId === id))
|
|
27
|
+
throw new AgentOpsError("RUN_LAND_TASK_INVALID", `${id} is not an unlanded delivered task of run ${state.runId}.`);
|
|
28
|
+
const selected = requested === undefined ? eligible : eligible.filter(task => requested.includes(task.taskId));
|
|
29
|
+
if (selected.length === 0)
|
|
30
|
+
throw new AgentOpsError("RUN_LAND_NOTHING", `Run ${state.runId} has no unlanded delivered task.`);
|
|
31
|
+
const ids = new Set(selected.map(task => task.taskId));
|
|
32
|
+
for (const task of selected)
|
|
33
|
+
for (const dependency of task.dependencies)
|
|
34
|
+
if (!ids.has(dependency) && !landed.has(dependency))
|
|
35
|
+
throw new AgentOpsError("RUN_LAND_DEPENDENCY_OPEN", `${task.taskId} depends on ${dependency}, which is neither selected nor landed.`);
|
|
36
|
+
return selected;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* One landing session per attempt: a non-hex first letter keeps it apart from
|
|
40
|
+
* UUID sessions, and a fresh path per attempt never reuses a removed checkout.
|
|
41
|
+
*/
|
|
42
|
+
export function landingSessionId(runId, nonce = randomUUID()) {
|
|
43
|
+
return `l${sha256(`${runId}:${nonce}`).slice(0, 7)}${LANDING_SUFFIX}${runId}`;
|
|
44
|
+
}
|
|
45
|
+
const LANDING_SUFFIX = "-land-";
|
|
46
|
+
async function git(deps, cwd, args) {
|
|
47
|
+
const result = await deps.git(cwd, args);
|
|
48
|
+
if (result.exitCode !== 0)
|
|
49
|
+
throw new AgentOpsError("RUN_LAND_GIT_FAILED", `${args.join(" ")}: ${result.stderr.trim()}`);
|
|
50
|
+
return result.stdout.trim();
|
|
51
|
+
}
|
|
52
|
+
/** The worker checkout whose committed head is the frozen delivery. */
|
|
53
|
+
async function deliveredCheckout(deps, state, task) {
|
|
54
|
+
for (const worker of state.workers.filter(worker => worker.taskId === task.taskId && worker.worktree !== null)) {
|
|
55
|
+
const record = await readWorktreeRecord(worker.worktree);
|
|
56
|
+
if (record !== null && await git(deps, record.path, ["rev-parse", "HEAD"]) === task.sourceCommit)
|
|
57
|
+
return record;
|
|
58
|
+
}
|
|
59
|
+
throw new AgentOpsError("RUN_LAND_DELIVERY_MISSING", `No preserved checkout of ${task.taskId} is at its delivered commit ${task.sourceCommit}.`);
|
|
60
|
+
}
|
|
61
|
+
async function ownerOf(path) {
|
|
62
|
+
try {
|
|
63
|
+
const owner = JSON.parse(await readPrivateFile(join(path, OWNER_FILE), path) ?? "null");
|
|
64
|
+
return typeof owner?.runId === "string" ? owner.runId : null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Worktrees kept by this run's failed attempts are removed first; one that claims the run without proof is refused. */
|
|
71
|
+
async function freshLandingWorktree(deps, state, sessionId) {
|
|
72
|
+
const claimed = (await listWorktrees(deps, state.root)).map(({ record }) => record)
|
|
73
|
+
.filter(record => record.sessionId.endsWith(LANDING_SUFFIX + state.runId));
|
|
74
|
+
for (const record of claimed)
|
|
75
|
+
if (await ownerOf(record.path) !== state.runId)
|
|
76
|
+
throw new AgentOpsError("RUN_LAND_WORKTREE_FOREIGN", `${record.path} claims run ${state.runId} without its owner record; it was left untouched.`);
|
|
77
|
+
for (const record of claimed)
|
|
78
|
+
await removeCheckout(deps, record, true);
|
|
79
|
+
const record = (await addWorktree(deps, { cwd: state.root, name: sessionWorktreeName(sessionId), sessionId,
|
|
80
|
+
from: state.targetBranch, targetBranch: state.targetBranch })).record;
|
|
81
|
+
await writePrivateFile(join(record.path, OWNER_FILE), JSON.stringify({ runId: state.runId }), record.path);
|
|
82
|
+
return record;
|
|
83
|
+
}
|
|
84
|
+
async function createLandingRoot(deps, state, record, selected) {
|
|
85
|
+
const config = await deps.loadConfig(record.path);
|
|
86
|
+
const required = config.verification.commands.filter(command => command.required).map(command => command.id);
|
|
87
|
+
if (required.length === 0)
|
|
88
|
+
throw new AgentOpsError("RUN_LAND_VERIFIER_REQUIRED", "Landing proof needs at least one required verification command.");
|
|
89
|
+
const coordinator = state.workers.find(worker => worker.workerId === state.coordinatorId)?.worktree ?? null;
|
|
90
|
+
const root = coordinator === null ? null : await deps.tasks(coordinator).status({ taskId: state.rootTaskId }).catch(() => null);
|
|
91
|
+
const ids = selected.map(task => task.taskId);
|
|
92
|
+
const base = await git(deps, record.path, ["rev-parse", "HEAD"]);
|
|
93
|
+
const criteria = [
|
|
94
|
+
{ id: "landed-verifiers", description: "Every required project verifier passes on the integrated candidate of the landed tasks.", verifierIds: required },
|
|
95
|
+
{ id: "landed-coherent", verifierIds: [],
|
|
96
|
+
description: "The landed subset is coherent on its own: it exposes no half-finished behavior and references no work of an undelivered run task.",
|
|
97
|
+
acceptance: { mode: "review-only", baselineCommit: base, bindings: [],
|
|
98
|
+
reviewOnlyReason: "partial-landing coherence: whether a subset stands alone without the undelivered tasks is a reviewer judgment, not a mechanical check" } }
|
|
99
|
+
];
|
|
100
|
+
// Typed acceptance needs repository context; the target head is the landing baseline.
|
|
101
|
+
const created = await deps.tasks(record.path, base).create({
|
|
102
|
+
title: `Land ${ids.join(", ")} from ${state.runId}`,
|
|
103
|
+
goal: `Land delivered tasks ${ids.join(", ")} of run ${state.runId} ahead of the rest of its goal:\n${state.goal}`.slice(0, 64_000),
|
|
104
|
+
intent: "Merge only the frozen deliveries of the listed run tasks; the run, its root task and its checkouts stay as they are.",
|
|
105
|
+
criteria, createdSourceCommit: base, policyConfigHash: calculateConfigHash(config), sessionId: record.sessionId,
|
|
106
|
+
...(root?.task.surface === undefined ? {} : { surface: root.task.surface })
|
|
107
|
+
});
|
|
108
|
+
return created.task.id;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* `agent-ops run land`: prove and merge delivered children of a stopped run
|
|
112
|
+
* while the run stays resumable. Landed tasks remain delivered, so a later
|
|
113
|
+
* resume and finalize re-verify the whole goal on the new target.
|
|
114
|
+
*/
|
|
115
|
+
export async function landRun(options) {
|
|
116
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
117
|
+
let selected = [];
|
|
118
|
+
const held = await options.repository.mutate(options.runId, current => {
|
|
119
|
+
if (landingMarkerLive(current.landing))
|
|
120
|
+
throw new AgentOpsError("RUN_LANDING_IN_PROGRESS", `Run ${current.runId} is already landing delivered work.`);
|
|
121
|
+
assertLandable(current);
|
|
122
|
+
selected = selectLanding(current, options.taskIds);
|
|
123
|
+
return { ...current, landing: { processId: process.pid, hostname: hostname(), startedAt: now() } };
|
|
124
|
+
});
|
|
125
|
+
const taskIds = selected.map(task => task.taskId);
|
|
126
|
+
try {
|
|
127
|
+
const children = [];
|
|
128
|
+
for (const task of selected)
|
|
129
|
+
children.push(await deliveredCheckout(options.deps, held, task));
|
|
130
|
+
const sessionId = landingSessionId(held.runId);
|
|
131
|
+
const record = await freshLandingWorktree(options.deps, held, sessionId);
|
|
132
|
+
const parentTaskId = await createLandingRoot(options.deps, held, record, selected);
|
|
133
|
+
const { receipt } = await options.advance({ cwd: held.root, sessionId, parentTaskId, children });
|
|
134
|
+
const target = await git(options.deps, held.root, ["rev-parse", `${held.targetBranch}^{commit}`]);
|
|
135
|
+
const state = await options.repository.mutate(options.runId, current => ({ ...current, landing: null,
|
|
136
|
+
landings: [...(current.landings ?? []), { taskIds, receipt, target, landedAt: now() }],
|
|
137
|
+
events: [...current.events, { id: randomUUID(), at: now(), type: "integration", code: "RUN_PARTIAL_LANDED",
|
|
138
|
+
workerId: null, taskId: null, detail: `Landed ${taskIds.join(", ")} at ${target}; receipt ${receipt}.` }].slice(-MAX_EVENTS) }));
|
|
139
|
+
return { state, taskIds, receipt };
|
|
140
|
+
}
|
|
141
|
+
catch (cause) {
|
|
142
|
+
await options.repository.mutate(options.runId, current => ({ ...current, landing: null })).catch(() => undefined);
|
|
143
|
+
throw cause;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { mkdir, readdir } from "node:fs/promises";
|
|
3
|
+
import { hostname } from "node:os";
|
|
3
4
|
import { basename, join } from "node:path";
|
|
4
5
|
import { sha256 } from "../fs/hash.js";
|
|
5
6
|
import { AgentOpsError } from "../fs/paths.js";
|
|
@@ -10,6 +11,28 @@ import { redactSecrets } from "../security/redact.js";
|
|
|
10
11
|
export const RUN_PHASES = ["planning", "implementing", "verifying", "reviewing", "integrating"];
|
|
11
12
|
/** Writer slots are deliberately bounded; the coordinator may occupy one. */
|
|
12
13
|
export const MAX_ACTIVE_WORKERS = 2;
|
|
14
|
+
/**
|
|
15
|
+
* A marker from another host is treated as live: its process cannot be probed.
|
|
16
|
+
*
|
|
17
|
+
* ponytail: a reused pid on this host reads as live; clear the marker by hand if that ever bites.
|
|
18
|
+
*/
|
|
19
|
+
export function landingMarkerLive(marker) {
|
|
20
|
+
if (marker == null)
|
|
21
|
+
return false;
|
|
22
|
+
if (marker.hostname !== hostname())
|
|
23
|
+
return true;
|
|
24
|
+
try {
|
|
25
|
+
process.kill(marker.processId, 0);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch (cause) {
|
|
29
|
+
return cause.code === "EPERM";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function refuseWhileLanding(state) {
|
|
33
|
+
if (landingMarkerLive(state.landing))
|
|
34
|
+
throw new AgentOpsError("RUN_LANDING_IN_PROGRESS", `Run ${state.runId} is landing delivered work; wait for run land to finish.`);
|
|
35
|
+
}
|
|
13
36
|
const RUN_ID = /^[a-z][a-z0-9-]{7,63}$/u;
|
|
14
37
|
const ID = /^[A-Za-z0-9._:/-]{1,256}$/u;
|
|
15
38
|
const SHA = /^[a-f0-9]{40,64}$/u;
|
|
@@ -127,6 +150,15 @@ export function assertRunState(value) {
|
|
|
127
150
|
return invalid("Run phase or task progress is invalid.");
|
|
128
151
|
if (state.policyBinding !== undefined && !validPolicyBinding(state.policyBinding))
|
|
129
152
|
return invalid("Invalid run policy binding.");
|
|
153
|
+
if (state.landings !== undefined && (!boundedArray(state.landings, 512) || state.landings.some(l => !plain(l) ||
|
|
154
|
+
!boundedArray(l.taskIds, 512) || l.taskIds.length === 0 || l.taskIds.some(id => !ID.test(String(id))) ||
|
|
155
|
+
typeof l.receipt !== "string" || l.receipt.length === 0 || l.receipt.length > 4096 ||
|
|
156
|
+
!SHA.test(String(l.target)) || !Number.isFinite(Date.parse(String(l.landedAt))))))
|
|
157
|
+
return invalid("Invalid run landing record.");
|
|
158
|
+
if (state.landing != null && (!plain(state.landing) || !Number.isSafeInteger(state.landing.processId) ||
|
|
159
|
+
state.landing.processId < 1 || typeof state.landing.hostname !== "string" || state.landing.hostname.length > 256 ||
|
|
160
|
+
!Number.isFinite(Date.parse(String(state.landing.startedAt)))))
|
|
161
|
+
return invalid("Invalid run landing marker.");
|
|
130
162
|
if (state.proofProcess != null && (!plain(state.proofProcess) || !Number.isSafeInteger(state.proofProcess.processId) ||
|
|
131
163
|
state.proofProcess.processId < 1 || typeof state.proofProcess.processIdentity !== "string" ||
|
|
132
164
|
state.proofProcess.processIdentity.length === 0 || state.proofProcess.processIdentity.length > 256))
|
|
@@ -442,6 +474,7 @@ export class RunService {
|
|
|
442
474
|
}
|
|
443
475
|
async resume(runId, retryApprovalIds = [], upgradeBudget = true, retryDeliveryTaskId, expectedFailureId) {
|
|
444
476
|
const state = await this.#repository.mutate(runId, async (current) => {
|
|
477
|
+
refuseWhileLanding(current);
|
|
445
478
|
if (retryDeliveryTaskId !== undefined && retryApprovalIds.length > 0)
|
|
446
479
|
throw new AgentOpsError("RUN_DELIVERY_RETRY_INVALID", "Delivery and approval retries require separate explicit controls.");
|
|
447
480
|
const retry = retryDeliveryTaskId === undefined ? null : validateDeliveryRetry(current, retryDeliveryTaskId, Date.parse(this.#now()));
|
|
@@ -491,6 +524,7 @@ export class RunService {
|
|
|
491
524
|
const cleanAnswer = bounded(answer.trim(), "answer", MAX_GOAL_LENGTH);
|
|
492
525
|
const state = await this.#repository.mutate(runId, (current) => {
|
|
493
526
|
const question = current.questions.find((item) => item.questionId === cleanQuestion);
|
|
527
|
+
refuseWhileLanding(current);
|
|
494
528
|
if (current.status !== "awaiting-input" || current.disableRestart)
|
|
495
529
|
throw new AgentOpsError("RUN_NOT_AWAITING_INPUT", "Respond cannot restart a stopped or terminal run.");
|
|
496
530
|
if (question === undefined)
|
|
@@ -11,8 +11,8 @@ import { globMatcher, isCovers, missingScenarios, parseSurface, SURFACE_AXES } f
|
|
|
11
11
|
/** Cheapest first: a budget overrun stops before the long tail of diff tokens. */
|
|
12
12
|
export const DISCOVERY_SOURCES = ["agent", "hunk-header", "template-fragment", "diff-token"];
|
|
13
13
|
export const SURFACE_LIMITS = {
|
|
14
|
-
// ponytail: derived queries over 10 files are skipped as generic; incidental ones under that are dismissed by query.
|
|
15
|
-
agentQueryFiles: 200, derivedQueryFiles: 10, exclusionFiles: 10, dismissals: 128, dismissMinFiles: 4, budgetMs: 120_000, queryTimeoutMs: 30_000,
|
|
14
|
+
// ponytail: derived queries over 10 files outside the diff are skipped as generic; incidental ones under that are dismissed by query.
|
|
15
|
+
agentQueryFiles: 200, derivedQueryFiles: 10, exclusionFiles: 10, inclusionFiles: 10, dismissals: 128, dismissMinFiles: 4, budgetMs: 120_000, queryTimeoutMs: 30_000,
|
|
16
16
|
tokenLength: 6, fragmentLength: 4, derivedQueries: 400
|
|
17
17
|
};
|
|
18
18
|
/** argv only: queries are task text and must never meet a shell or a regex engine. */
|
|
@@ -142,14 +142,23 @@ export async function runSurfaceGate(input) {
|
|
|
142
142
|
const tracked = await git(input.root, ["ls-files", "-z"], Math.min(SURFACE_LIMITS.queryTimeoutMs, Math.max(1, remaining())));
|
|
143
143
|
if (tracked.timedOut || remaining() <= 0)
|
|
144
144
|
return notRun("SURFACE_BUDGET_EXCEEDED", "Discovery budget exhausted listing files.", []);
|
|
145
|
+
const trackedFiles = zList(tracked.stdout);
|
|
145
146
|
const exclusions = surface.excluded.map(row => ({ ...row, match: globMatcher(row.glob) }));
|
|
146
147
|
for (const row of exclusions) {
|
|
147
|
-
const files =
|
|
148
|
+
const files = trackedFiles.filter(row.match);
|
|
148
149
|
const diffFiles = [...changed].filter(row.match);
|
|
149
150
|
if (files.length > SURFACE_LIMITS.exclusionFiles || diffFiles.length > 0)
|
|
150
151
|
return notRun("SURFACE_EXCLUSION_TOO_BROAD", diffFiles.length > 0 ? "Files in the diff cannot be excluded."
|
|
151
152
|
: `One exclusion may hide at most ${SURFACE_LIMITS.exclusionFiles} files.`, [{ glob: row.glob, files: files.length, diffFiles }]);
|
|
152
153
|
}
|
|
154
|
+
// An included or UNKNOWN glob classifies every hit it matches, so it is capped like an exclusion.
|
|
155
|
+
for (const glob of [...surface.included.map(row => row.path), ...surface.unknown.flatMap(row => row.path === undefined ? [] : [row.path])]) {
|
|
156
|
+
const match = globMatcher(glob);
|
|
157
|
+
const files = trackedFiles.filter(path => match(path) && !changed.has(path)).length;
|
|
158
|
+
if (files > SURFACE_LIMITS.inclusionFiles)
|
|
159
|
+
return notRun("SURFACE_INCLUSION_TOO_BROAD", `One included or UNKNOWN glob may cover at most ${SURFACE_LIMITS.inclusionFiles} ` +
|
|
160
|
+
"files outside the diff; list narrower paths, each with its own behavior.", [{ glob, files }]);
|
|
161
|
+
}
|
|
153
162
|
const head = (await git(input.root, ["rev-parse", "HEAD"], SURFACE_LIMITS.queryTimeoutMs)).stdout.toString("utf8").trim();
|
|
154
163
|
const diff = (await git(input.root, ["diff", "-U0", "--no-color", "--no-ext-diff", input.base ?? "HEAD", "--"], SURFACE_LIMITS.queryTimeoutMs)).stdout.toString("utf8");
|
|
155
164
|
const derived = extractDiffQueries(diff);
|
|
@@ -217,7 +226,7 @@ export async function runSurfaceGate(input) {
|
|
|
217
226
|
return notRun("SURFACE_DISMISSAL_INVALID", "A recorded axis query cannot be dismissed; classify its hits.", [{ query }]);
|
|
218
227
|
const hit = hits.find(row => row.query === query && row.source !== "agent");
|
|
219
228
|
const outside = hit === undefined ? [] : hit.files.filter(path => !changed.has(path));
|
|
220
|
-
if (hit !== undefined && outside.length < SURFACE_LIMITS.dismissMinFiles
|
|
229
|
+
if (hit !== undefined && outside.length < SURFACE_LIMITS.dismissMinFiles)
|
|
221
230
|
return notRun("SURFACE_DISMISSAL_INVALID", `A query reaching fewer than ${SURFACE_LIMITS.dismissMinFiles} files outside the diff ` +
|
|
222
231
|
"is the likeliest sibling signal and cannot be dismissed; classify its files.", [{ query, files: outside }]);
|
|
223
232
|
if (hit !== undefined)
|
|
@@ -227,8 +236,9 @@ export async function runSurfaceGate(input) {
|
|
|
227
236
|
sources[hit.source].queries += 1;
|
|
228
237
|
if (hit.source === "agent" && hit.files.length > SURFACE_LIMITS.agentQueryFiles)
|
|
229
238
|
return notRun("SURFACE_QUERY_TOO_BROAD", `Query hits more than ${SURFACE_LIMITS.agentQueryFiles} files; record a more specific fixed string.`, [{ query: hit.query, files: hit.files.length }]);
|
|
230
|
-
|
|
231
|
-
|
|
239
|
+
const outside = hit.files.filter(path => !changed.has(path)).length;
|
|
240
|
+
if (hit.source !== "agent" && outside > SURFACE_LIMITS.derivedQueryFiles) {
|
|
241
|
+
skipped.push({ source: hit.source, query: hit.query, files: outside });
|
|
232
242
|
continue;
|
|
233
243
|
}
|
|
234
244
|
if (hit.source !== "agent" && dismissals.has(hit.query))
|
|
@@ -284,7 +294,8 @@ export async function runSurfaceGate(input) {
|
|
|
284
294
|
return notRun("ESCAPE_RULE_NO_EXCLUDE", "A learned escape rule marks these places noExclude: include them or mark them UNKNOWN.", noExclude);
|
|
285
295
|
for (const source of DISCOVERY_SOURCES)
|
|
286
296
|
sources[source].files = metrics.get(source).hits.size;
|
|
287
|
-
const discovery = { cached, sources, skipped: skipped
|
|
297
|
+
const discovery = { cached, sources, skipped: skipped
|
|
298
|
+
.sort((a, b) => a.files - b.files || (a.query < b.query ? -1 : a.query > b.query ? 1 : 0)).slice(0, 50), postBlockExclusions, dismissed };
|
|
288
299
|
if (input.metrics === true)
|
|
289
300
|
await logMetrics(input.logHome ?? process.env.AGENT_OPS_HOME ?? homedir(), DISCOVERY_SOURCES.map(source => ({ source,
|
|
290
301
|
hits: metrics.get(source).hits.size, included: metrics.get(source).included.size, excluded: metrics.get(source).excluded.size,
|
|
@@ -99,6 +99,9 @@ Every new task MUST record a surface audit of where else its change lands before
|
|
|
99
99
|
`glob` and a reason saying why the surface is unaffected ("out of scope" is
|
|
100
100
|
not a reason); `unknown` rows name a reader the repository cannot see.
|
|
101
101
|
`contracts` declare `compatible` or `breaking`. A single surface is allowed.
|
|
102
|
+
An `included` or `unknown` path glob may cover at most 10 tracked files
|
|
103
|
+
outside the diff, like an exclusion; list narrower paths, each with its own
|
|
104
|
+
behavior.
|
|
102
105
|
- `valueChanges` `[{producer, kind, change, consumers, indirectConsumers}]`
|
|
103
106
|
list two hops whenever a value's semantics change (clear, redact, default,
|
|
104
107
|
normalize, unit/format).
|
|
@@ -114,8 +117,9 @@ Every new task MUST record a surface audit of where else its change lands before
|
|
|
114
117
|
template-literal fixed fragments (at least 4 characters, separator-bounded,
|
|
115
118
|
such as `order:`, `user_`, `menu.`); compound identifiers (snake_case,
|
|
116
119
|
camelCase or containing digits, so prose words are not queries) and string
|
|
117
|
-
literals from added and removed lines (at least 6 characters
|
|
118
|
-
|
|
120
|
+
literals from added and removed lines (at least 6 characters). A derived
|
|
121
|
+
query is skipped as too generic above 10 hit files outside the diff; skipped
|
|
122
|
+
queries, fewest hits first, go to the second reviewer. Every hit outside the diff and the surface lists stops review.
|
|
119
123
|
- A derived query (diff token, template fragment or hunk-header name) whose hits
|
|
120
124
|
are incidental may be dismissed once in the surface's optional `dismissed`
|
|
121
125
|
list (`{query, reason}`, reason at most 256 characters, "out of scope" is
|
|
@@ -132,7 +136,8 @@ Every new task MUST record a surface audit of where else its change lands before
|
|
|
132
136
|
also refused at create), `SURFACE_UNCLASSIFIED`,
|
|
133
137
|
`SURFACE_QUERY_TOO_BROAD` (one recorded query over 200 files),
|
|
134
138
|
`SURFACE_EXCLUSION_TOO_BROAD` (one glob over 10 files, or any file in the
|
|
135
|
-
diff), `
|
|
139
|
+
diff), `SURFACE_INCLUSION_TOO_BROAD` (one included or UNKNOWN glob over 10
|
|
140
|
+
files outside the diff), `SURFACE_DISMISSAL_TOO_BROAD` (over 128 dismissals),
|
|
136
141
|
`SURFACE_DISMISSAL_INVALID` (a recorded or rare query), `SCENARIO_MISSING`, `SURFACE_BUDGET_EXCEEDED` (120 s total, never
|
|
137
142
|
silently skipped) or `SURFACE_UNASSIGNED` (children's covers differ from the
|
|
138
143
|
parent's included list; checked by every review, including advance and batch).
|
|
@@ -174,7 +174,8 @@ removes only `run` and keeps `loop`.
|
|
|
174
174
|
ends without completing (blocked, budget exhausted, stopped, failing review)
|
|
175
175
|
is reported with its `run status` state and code, its last `run logs` events,
|
|
176
176
|
and the exact `agent-ops run resume <id>` and `agent-ops run stop <id>`
|
|
177
|
-
commands,
|
|
177
|
+
commands, plus `agent-ops run land <id> --yes` when it has delivered tasks,
|
|
178
|
+
and is never resumed or landed automatically or taken over by the session.
|
|
178
179
|
With worktree auto mode, the Claude `PreToolUse` worktree guard allows a
|
|
179
180
|
write to exactly `.agent-ops/state/run-goal.md` in the main checkout without
|
|
180
181
|
creating a worktree. Confirmed init/update pre-authorizes `agent-ops run`:
|
|
@@ -73,6 +73,19 @@
|
|
|
73
73
|
Interrupted controlled proof MUST record bounded redacted exit/signal, result
|
|
74
74
|
failure stage and subprocess diagnostics without treating invalid output or a
|
|
75
75
|
nonzero successful envelope as proof.
|
|
76
|
+
- `run land <id> [--task <task-id>]... --yes` MAY merge delivered children of a
|
|
77
|
+
paused, blocked or budget-limited run with no live worker ahead of the rest.
|
|
78
|
+
Every selected task's dependencies MUST be selected or already landed
|
|
79
|
+
(`RUN_LAND_DEPENDENCY_OPEN`). Proof runs in a fresh landing worktree from the
|
|
80
|
+
target under a generated root task (required verifiers plus a review-only
|
|
81
|
+
coherence criterion, the run root's surface), then the usual verification,
|
|
82
|
+
two-round tree review and receipt-bound finish. The run's checkouts are pinned
|
|
83
|
+
and never removed; landed tasks stay delivered, the run stays resumable and
|
|
84
|
+
records `landings[]` and `RUN_PARTIAL_LANDED`, and its final proof re-verifies
|
|
85
|
+
the whole goal on the new target. While landing, a marker refuses resume,
|
|
86
|
+
respond and another land (`RUN_LANDING_IN_PROGRESS`); a marker of a process
|
|
87
|
+
that no longer exists does not block. A failure leaves the target unchanged
|
|
88
|
+
and keeps the landing worktree until the next land removes it.
|
|
76
89
|
Initial recovery after a current manual resume MAY ignore an owned Codex limit
|
|
77
90
|
snapshot received before activation whose native update second strictly predates
|
|
78
91
|
that resume. Automatic restarts, unknown/equal/future timestamps, clock rollback,
|
package/docs/en/spec/review.md
CHANGED
|
@@ -108,7 +108,9 @@ successful refutation MUST make the run FAIL.
|
|
|
108
108
|
session. A FAIL is already terminal and is never re-checked.
|
|
109
109
|
- Note: For a surfaced task the second round also hunts unlisted surfaces and
|
|
110
110
|
scenarios under `surface-coverage`: it checks the five fixed scenario
|
|
111
|
-
dimensions and may run at most 3 new `git grep -F` queries per axis
|
|
111
|
+
dimensions and may run at most 3 new `git grep -F` queries per axis, plus at
|
|
112
|
+
most 3 in total over the derived queries discovery skipped as too generic,
|
|
113
|
+
which the prompt names with their hit counts. Round 1
|
|
112
114
|
replays the recorded queries and reads every listed `valueChanges` consumer;
|
|
113
115
|
this is the only exception to "do not survey the repository".
|
|
114
116
|
|
|
@@ -62,7 +62,8 @@ English source version: 2026-10-20. Revalidate: when the English specification c
|
|
|
62
62
|
- `included` 列含 `id`、`path` glob 與 `behavior`;`excluded` 列含 `glob` 與說明
|
|
63
63
|
該 surface 為何不受影響的理由(「out of scope」不是理由);`unknown` 列記錄
|
|
64
64
|
repository 看不到的讀取者。`contracts` 宣告 `compatible` 或 `breaking`。只有單一
|
|
65
|
-
surface
|
|
65
|
+
surface 也可以。`included` 或 `unknown` 的 path glob 與排除一樣,至多涵蓋 diff
|
|
66
|
+
以外 10 個 tracked 檔案;請改列更窄的路徑,各自寫出 behavior。
|
|
66
67
|
- 值的語意改變(clear、redact、default、normalize、unit/format)時,
|
|
67
68
|
`valueChanges` `[{producer, kind, change, consumers, indirectConsumers}]` 列出兩層
|
|
68
69
|
consumer。
|
|
@@ -75,8 +76,8 @@ English source version: 2026-10-20. Revalidate: when the English specification c
|
|
|
75
76
|
不以 task 文字組 regex),由便宜的來源先跑:記錄的查詢;diff hunk header 的外層
|
|
76
77
|
函式名;template literal 固定片段(至少 4 字元且以分隔符界定,例如 `order:`、
|
|
77
78
|
`user_`、`menu.`);新增與刪除行的複合識別字(snake_case、camelCase 或含數字,
|
|
78
|
-
因此一般文字單字不會成為查詢)與字串常值(至少 6
|
|
79
|
-
|
|
79
|
+
因此一般文字單字不會成為查詢)與字串常值(至少 6 字元)。衍生查詢在 diff 以外命中
|
|
80
|
+
超過 10 個檔案時視為太通用而略過;略過的查詢依命中數由少到多交給第二輪 reviewer。diff 與 surface 清單之外的任何命中都會擋下 review。
|
|
80
81
|
- 衍生查詢(diff 字詞、template 片段或 hunk header 函式名)的命中若只是巧合,可在
|
|
81
82
|
surface 選用的 `dismissed` 清單中以一筆 `{query, reason}` 排除(理由至多 256
|
|
82
83
|
字元,「out of scope」不是理由),不必逐檔排除。記錄的軸查詢與 escape rule 命中
|
|
@@ -88,7 +89,8 @@ English source version: 2026-10-20. Revalidate: when the English specification c
|
|
|
88
89
|
- Review 以 NOT_RUN 回報:`SURFACE_MISSING`(沒有或無法解析的 surface;
|
|
89
90
|
task 建立時也會拒絕)、`SURFACE_UNCLASSIFIED`、`SURFACE_QUERY_TOO_BROAD`(單一
|
|
90
91
|
記錄查詢超過 200 個檔案)、`SURFACE_EXCLUSION_TOO_BROAD`(單一 glob 超過 10 個
|
|
91
|
-
檔案,或包含 diff 內的檔案)、`
|
|
92
|
+
檔案,或包含 diff 內的檔案)、`SURFACE_INCLUSION_TOO_BROAD`(單一 included 或
|
|
93
|
+
UNKNOWN glob 涵蓋 diff 以外超過 10 個檔案)、`SURFACE_DISMISSAL_TOO_BROAD`(超過 128 筆排除)、
|
|
92
94
|
`SURFACE_DISMISSAL_INVALID`(記錄的查詢或稀有查詢)、`SCENARIO_MISSING`、`SURFACE_BUDGET_EXCEEDED`
|
|
93
95
|
(總計 120 秒,絕不默默略過)或 `SURFACE_UNASSIGNED`(子 task covers 與父 task
|
|
94
96
|
included 不一致;每次 review 都檢查,包含 advance 與 batch)。
|
|
@@ -150,8 +150,8 @@ byte-identical。Update MUST 保留此 profile,除非提供 `--auto-run on|off
|
|
|
150
150
|
一行說明該 code,並退回 subtask flow;`RUN_REPO_UNTRUSTED` 則停止並詢問使用者。
|
|
151
151
|
未完成即結束的 run(blocked、budget exhausted、stopped、review 失敗)須回報其
|
|
152
152
|
`run status` state 與 code、最後的 `run logs` event,以及確切的
|
|
153
|
-
`agent-ops run resume <id>` 與 `agent-ops run stop <id>` command
|
|
154
|
-
resume 或由 session 接手。在 worktree auto mode 下,Claude `PreToolUse`
|
|
153
|
+
`agent-ops run resume <id>` 與 `agent-ops run stop <id>` command;有已交付 task
|
|
154
|
+
時另附 `agent-ops run land <id> --yes`,且永不自動 resume、land 或由 session 接手。在 worktree auto mode 下,Claude `PreToolUse`
|
|
155
155
|
worktree guard 允許在 main checkout 寫入恰為 `.agent-ops/state/run-goal.md`
|
|
156
156
|
的路徑,且不建立 worktree。確認後的 init/update 會預先授權 `agent-ops run`:
|
|
157
157
|
Claude Code 為 `Bash(agent-ops run *)`,Codex 為
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
- 執行中的 run 僅在原始 repository trust、config、可執行 runtime 與操作範圍完全相同時,自動續期過期的有限授權。續期以 digest 綁定前一版本並保留既有評估,拒絕無效/倒退時鐘;並行 stop、denial 或 scope 變更使續期失敗。權限已變更或撤銷時須沿既有 trust/resume 流程取得真正授權;續期不授予永久 trust,也不消除 denial。明確 resume 先核對不可變 renewal lineage,再同步 policy。回答問題也沿用相同恢復檢查;兩者皆不得復活已被明確拒絕的工作。
|
|
23
23
|
- `run resume <id> --retry-approval <auth-event-id>` MAY 明確重試以額度不足 wrapper 失敗的 Codex 自動審批。每筆指定失敗 MUST 保有原始 private artifact、digest、worker identity 與 policy lineage;真正或未指定的拒絕仍阻擋。多筆失敗可重複旗標。成功確認會留存並在後續 resume 重新驗證;此操作不授予命令權限,原生審批必須重跑,既有 writer 死亡、checkout、trust 與預算檢查仍適用。
|
|
24
24
|
- `run resume <id> --retry-delivery <task-id>` MAY 在修復 harness 原因後,明確重新排程一個被 `RUN_FINAL_PROOF_INTERRUPTED` 阻擋的 child。MUST 核對同一筆中斷 handoff、所有 writer 與 proof 已確認停止、所屬 checkout 乾淨及目前 trust/policy;保留 run、task、goal、baselines、commits、耗用與失敗歷史,記錄已確認的 attempt,並要求新 native generation 及正常交付驗證。MUST NOT 直接標記交付、解除其他阻擋或確認命令拒絕;approval retry 是獨立操作。一般 resume 保留 no-progress 阻擋。受控 proof 中斷 MUST 記錄有界且已遮蔽的 exit/signal、失敗階段與子程序診斷;無效輸出或非零退出的成功 envelope 不得視為證據。
|
|
25
|
+
- `run land <id> [--task <task-id>]... --yes` MAY 在 run 為 paused、blocked 或 budget-limited 且無存活 worker 時,先行合併已交付的 child。每個選取 task 的依賴 MUST 已選取或已落地(`RUN_LAND_DEPENDENCY_OPEN`)。證明在由 target 建立的全新 landing worktree 中進行,使用自動產生的 root task(required verifier 加上 review-only 一致性條件,surface 沿用 run root),再走一般驗證、兩輪 tree review 與綁定 receipt 的 finish。run 的 checkout 只被釘住、永不移除;已落地 task 維持 delivered,run 仍可 resume 並記錄 `landings[]` 與 `RUN_PARTIAL_LANDED`,其最終證明會在新 target 上重新驗證整個 goal。落地期間的標記會拒絕 resume、respond 與另一次 land(`RUN_LANDING_IN_PROGRESS`);已不存在的程序留下的標記不會阻擋。失敗時 target 不變,landing worktree 保留到下次 land 移除。
|
|
25
26
|
- 當次人工 resume 的初始恢復 MAY 忽略啟用前收到、且 native 更新秒數嚴格早於該次 resume 的本執行緒 Codex 額度快照。自動重啟、未知/同秒/未來時間、時鐘倒退,以及啟用開始後收到的所有額度事件仍有效。Native 啟用 MUST 確認 goal 為 active,才能啟動或轉向 turn。人工 resume 可解除衍生的 `RUN_POLICY_DISABLED` task 阻擋,但保留 ID、耗用、歷史及其他產品失敗。
|
|
26
27
|
- 收斂判斷比較實際 per-check 狀態及未解除的 finding pins;source fingerprint、時間或診斷文字本身不能算進展。
|
|
27
28
|
|
|
@@ -98,7 +98,7 @@ PASS MUST 交給第二個規劃的 target 嘗試反駁,且反駁成立時 MUST
|
|
|
98
98
|
- Positive: `codex 判 PASS,claude 找到 blocking 缺陷,整體判定 FAIL。`
|
|
99
99
|
- Negative: `為了讓複查看起來有效而編造反駁。`
|
|
100
100
|
- Note: 只有一個設定 target 時,會在全新 session 再呼叫同一 target。FAIL 已是終局,不再複查。
|
|
101
|
-
- Note: 有 surface 的 task,第二輪也會在 `surface-coverage` 下搜尋未列出的 surface 與情境:檢查五個固定情境維度,每個軸向最多可新增 3 個 `git grep -F`
|
|
101
|
+
- Note: 有 surface 的 task,第二輪也會在 `surface-coverage` 下搜尋未列出的 surface 與情境:檢查五個固定情境維度,每個軸向最多可新增 3 個 `git grep -F` 查詢,另外可針對 discovery 因太通用而略過的衍生查詢(prompt 會列出它們與命中數)總計最多再跑 3 個。第一輪重放記錄的查詢並閱讀每個列出的 `valueChanges` consumer;這是「不要普查 repository」唯一的例外。
|
|
102
102
|
|
|
103
103
|
## REVIEW-ORIGIN-001
|
|
104
104
|
|
package/package.json
CHANGED