@kylecheng3146/agent-ops 0.11.3 → 0.11.4

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 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; it is never resumed automatically. agy keeps the subtask flow.
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
- // An installation with no verifier looks finished and is not: every task
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, so no task can be completed. " +
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
  ? ""
@@ -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
- * Whether anything can ever be verified here. Completion requires current PASS
384
- * evidence from a required verifier, so an empty list is not a light
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, so no task can be completed.",
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
- "then stop: never resume automatically or take over the run's work.",
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 && child.sessionId === record.sessionId &&
323
- (child.agentId !== undefined || (record.runId !== undefined && child.runId === record.runId)));
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
- const statuses = await listWorktrees(deps, coordinator.mainRoot);
161
- const children = statuses.map(({ record }) => record)
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);
@@ -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)
@@ -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, and is never resumed automatically or taken over by the session.
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,
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",