agent-conveyor 0.1.11 → 0.1.13

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
@@ -174,7 +174,10 @@ thread identity through `--worker-codex-app-thread-id` and
174
174
  deliver the generated `worker_handoff` bootstrap prompt. The raw terminal
175
175
  `conveyor` CLI does not create Codex app threads by itself; if app thread tools
176
176
  are unavailable, open a separate Codex app worker manually and paste the
177
- `worker_handoff` prompt.
177
+ `worker_handoff` prompt. After a worker consumes a manager instruction, its
178
+ completion or blocker report must go back through the generated
179
+ `enqueue-notify-manager` command and a bounded Dispatch watch tick; a direct
180
+ Codex app final answer is not a durable manager receipt.
178
181
 
179
182
  Dispatch is core infrastructure for supervised worker/manager pairs. The
180
183
  `pair` workflow starts a detached Dispatch watch process by default so worker
@@ -371,8 +374,12 @@ tmux attach -t codex-live-test
371
374
  Codex app sessions, the JSON output also includes
372
375
  `heartbeat_recommendations` with role-specific poll prompts; Dispatch can
373
376
  deliver into those inboxes, but a heartbeat or operator wake-up is still
374
- required to make an idle app thread poll autonomously. Those recommendations
375
- also include `wakeup_dispatch_command` and `delivery_receipt_commands` for
377
+ required to make an idle app thread poll autonomously. The worker handoff and
378
+ worker heartbeat prompt also include the exact durable
379
+ `enqueue-notify-manager` and one-iteration `dispatch --watch` commands that a
380
+ worker must run after completing or blocking on a consumed item. Those
381
+ recommendations also include `wakeup_dispatch_command` and
382
+ `delivery_receipt_commands` for
376
383
  app-thread wake recovery. Use them to record sent, skipped, and blocked wake
377
384
  outcomes after `app-wakeup-dispatch`; an app-thread send is not task
378
385
  completion. The recommendations include a `teardown_policy`: an idle poll is
@@ -404,6 +411,21 @@ tmux attach -t codex-live-test
404
411
  `sent` is accepted only when the referenced `app-wakeup-dispatch` receipt has
405
412
  a matching `ready_to_send` action with `send_ready=true` and the same thread
406
413
  id; healthy and blocked roles must be recorded as `skipped` or `blocked`.
414
+ - `app-autopilot start|stop|status TASK [--dispatcher-id ID]
415
+ [--interval SECONDS] [--watch-iterations N] [--stale-after N]
416
+ [--quiet-after N] [--json]` —
417
+ Manage the pair-level app-native heartbeat policy for the active
418
+ manager/worker binding. `start` and `stop` write telemetry receipts and emit
419
+ the exact manager/worker Codex app heartbeat automation specs plus the
420
+ bounded Dispatch watch command. A plain shell CLI cannot call Codex app
421
+ thread tools, so create/pause those heartbeat automations from a Codex app
422
+ operator session using the emitted specs; Conveyor remains the durable source
423
+ of truth through Dispatch, inboxes, wake receipts, and app heartbeat status.
424
+ `status` also reports `plan.quiescence`: when the loop is healthy, has no
425
+ `next_actions`, and both roles have produced `--quiet-after` paired
426
+ heartbeats since the last command or inbox-consumption receipt, it recommends
427
+ `stop_autopilot` so operators can quiesce blocked/no-progress loops instead
428
+ of repeating idle pulses.
407
429
  - `discover [QUERY] [--all] [--limit N]` / `search [QUERY]` — Search tasks,
408
430
  registered sessions, active bindings, and recent telemetry in one JSON result.
409
431
  Use this for conversational setup when a manager or Codex session needs to
@@ -631,7 +653,10 @@ tmux attach -t codex-live-test
631
653
  recovery; `--once` performs one pass.
632
654
  - `enqueue-notify-manager <task> --message "..." [--correlation-id C]
633
655
  [--required-permission P] [--idempotency-key K] [--json]` — Queue a `notify_manager` command row for
634
- Dispatch to claim and deliver to the bound manager.
656
+ Dispatch to claim and deliver to the bound manager. Codex app/no-tmux
657
+ workers must use this route for completion and blocker reports after
658
+ consuming a manager instruction; direct app-thread final answers are local
659
+ text, not manager inbox receipts.
635
660
  - `enqueue-nudge-worker <task> --message "..." [--correlation-id C]
636
661
  [--required-permission P] [--idempotency-key K] [--json]` — Queue a `nudge_worker` command row for
637
662
  Dispatch to claim and deliver to the bound worker. Use this dispatcher-backed
@@ -784,7 +809,10 @@ same-project `create_thread` worker plus `set_thread_title` before creating the
784
809
  binding, then pass the worker thread id/title into Conveyor. Use `fork_thread`
785
810
  only when the user explicitly asks to fork or resume this conversation. If app
786
811
  thread tools are unavailable, create the binding anyway and paste the returned
787
- `worker_handoff` prompt into a manually opened worker session.
812
+ `worker_handoff` prompt into a manually opened worker session. The handoff
813
+ requires a worker to report completion/blockers through
814
+ `enqueue-notify-manager` plus a bounded Dispatch watch run before treating the
815
+ manager as notified.
788
816
  - `enqueue-continue-iteration TASK --loop-run RUN --requested-iteration N` —
789
817
  Queue a manager-requested next loop pass for Dispatch. The command refuses
790
818
  same/current iteration requests before they become pending queue rows, while
@@ -5,7 +5,7 @@ import { homedir, tmpdir } from "node:os";
5
5
  import { dirname, join, relative, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { taskAuditSync } from "../runtime/audit.js";
8
- import { appLoopStatusSync, appWakeupDispatchPlanSync, appWakeupPlanSync, directInboxPollCommand, } from "../runtime/app-autonomy.js";
8
+ import { appAutopilotPlanSync, appLoopStatusSync, appWakeupDispatchPlanSync, appWakeupPlanSync, directInboxPollCommand, } from "../runtime/app-autonomy.js";
9
9
  import { classifyBusyWait, classifyStartupOutput } from "../runtime/classify.js";
10
10
  import { exportTaskSync } from "../runtime/export.js";
11
11
  import { ingestSessionSync } from "../runtime/ingest.js";
@@ -134,6 +134,9 @@ export function runTypescriptRuntimeCommand(options) {
134
134
  if (parsed.command === "app-wakeup-record-delivery") {
135
135
  return runAppWakeupRecordDeliveryCommand(parsed, options);
136
136
  }
137
+ if (parsed.command === "app-autopilot") {
138
+ return runAppAutopilotCommand(parsed, options);
139
+ }
137
140
  if (parsed.command === "qa-plan") {
138
141
  return runQaPlanCommand(parsed);
139
142
  }
@@ -385,6 +388,17 @@ function commandHelpText(program, command) {
385
388
  ` ${program} enqueue-nudge-worker my-task --message "Status and evidence?" --path /tmp/work/workerctl.db`,
386
389
  ` ${program} dispatch --once --type nudge_worker --path /tmp/work/workerctl.db`,
387
390
  ],
391
+ "app-autopilot": [
392
+ `usage: ${program} app-autopilot start|stop|status <task> [--dispatcher-id ID] [--interval SECONDS] [--watch-iterations N] [--stale-after N] [--quiet-after N] ${path} [--json]`,
393
+ "",
394
+ "Manage the app-native heartbeat policy for a bound manager/worker pair.",
395
+ "The CLI records policy receipts and emits Codex app heartbeat automation specs; app-thread automation creation still happens through Codex app tools.",
396
+ "",
397
+ "Examples:",
398
+ ` ${program} app-autopilot start dogfood --dispatcher-id dispatch-local --path /tmp/work/workerctl.db --json`,
399
+ ` ${program} app-autopilot status dogfood --path /tmp/work/workerctl.db`,
400
+ ` ${program} app-autopilot stop dogfood --path /tmp/work/workerctl.db --json`,
401
+ ],
388
402
  pair: [
389
403
  `usage: ${program} pair --task <task> --worker-name <worker> --manager-name <manager> [options] ${path}`,
390
404
  "",
@@ -514,6 +528,7 @@ function parseRuntimeArgs(args, env) {
514
528
  source: null,
515
529
  proof: null,
516
530
  purpose: null,
531
+ quietAfterCycles: 3,
517
532
  questions: false,
518
533
  rationale: null,
519
534
  receiptOutput: null,
@@ -1496,7 +1511,8 @@ function parseRuntimeArgs(args, env) {
1496
1511
  && command !== "app-heartbeat"
1497
1512
  && command !== "app-loop-status"
1498
1513
  && command !== "app-wakeup-plan"
1499
- && command !== "app-wakeup-dispatch") {
1514
+ && command !== "app-wakeup-dispatch"
1515
+ && command !== "app-autopilot") {
1500
1516
  return { command, enabled, error: "Unsupported TypeScript runtime option: --dispatcher-id", explicit, flags, task };
1501
1517
  }
1502
1518
  const value = valueAfter(queue, index, arg);
@@ -2463,7 +2479,11 @@ function parseRuntimeArgs(args, env) {
2463
2479
  index += 1;
2464
2480
  }
2465
2481
  else if (arg === "--stale-after") {
2466
- if (command !== "app-heartbeat" && command !== "app-loop-status" && command !== "app-wakeup-plan" && command !== "app-wakeup-dispatch") {
2482
+ if (command !== "app-heartbeat"
2483
+ && command !== "app-loop-status"
2484
+ && command !== "app-wakeup-plan"
2485
+ && command !== "app-wakeup-dispatch"
2486
+ && command !== "app-autopilot") {
2467
2487
  return { command, enabled, error: "Unsupported TypeScript runtime option: --stale-after", explicit, flags, task };
2468
2488
  }
2469
2489
  const parsedValue = valueAfter(queue, index, arg);
@@ -2477,6 +2497,21 @@ function parseRuntimeArgs(args, env) {
2477
2497
  flags.appStaleAfterSeconds = value;
2478
2498
  index += 1;
2479
2499
  }
2500
+ else if (arg === "--quiet-after") {
2501
+ if (command !== "app-autopilot") {
2502
+ return { command, enabled, error: "Unsupported TypeScript runtime option: --quiet-after", explicit, flags, task };
2503
+ }
2504
+ const parsedValue = valueAfter(queue, index, arg);
2505
+ if (parsedValue.error) {
2506
+ return { command, enabled, error: parsedValue.error, explicit, flags, task };
2507
+ }
2508
+ const value = Number(parsedValue.value);
2509
+ if (!Number.isInteger(value) || value < 0) {
2510
+ return { command, enabled, error: "--quiet-after must be a non-negative integer.", explicit, flags, task };
2511
+ }
2512
+ flags.quietAfterCycles = value;
2513
+ index += 1;
2514
+ }
2480
2515
  else if (arg === "--terminal-stale-seconds") {
2481
2516
  if (command !== "idle-check") {
2482
2517
  return { command, enabled, error: "Unsupported TypeScript runtime option: --terminal-stale-seconds", explicit, flags, task };
@@ -2676,7 +2711,7 @@ function parseRuntimeArgs(args, env) {
2676
2711
  index += 1;
2677
2712
  }
2678
2713
  else if (arg === "--interval") {
2679
- if (command !== "dispatch" && command !== "session-inbox" && command !== "manager-inbox" && command !== "worker-inbox") {
2714
+ if (command !== "dispatch" && command !== "session-inbox" && command !== "manager-inbox" && command !== "worker-inbox" && command !== "app-autopilot") {
2680
2715
  return { command, enabled, error: "Unsupported TypeScript runtime option: --interval", explicit, flags, task };
2681
2716
  }
2682
2717
  const parsedValue = valueAfter(queue, index, arg);
@@ -2691,7 +2726,7 @@ function parseRuntimeArgs(args, env) {
2691
2726
  index += 1;
2692
2727
  }
2693
2728
  else if (arg === "--watch-iterations") {
2694
- if (command !== "dispatch") {
2729
+ if (command !== "dispatch" && command !== "app-autopilot") {
2695
2730
  return { command, enabled, error: "Unsupported TypeScript runtime option: --watch-iterations", explicit, flags, task };
2696
2731
  }
2697
2732
  const parsedValue = valueAfter(queue, index, arg);
@@ -2824,6 +2859,12 @@ function parseRuntimeArgs(args, env) {
2824
2859
  }
2825
2860
  flags.subtype = arg;
2826
2861
  }
2862
+ else if (command === "app-autopilot" && flags.action === null) {
2863
+ if (!["start", "stop", "status"].includes(arg)) {
2864
+ return { command, enabled, error: `Unsupported app-autopilot action: ${arg}`, explicit, flags, task };
2865
+ }
2866
+ flags.action = arg;
2867
+ }
2827
2868
  else if ((command === "qa-plan" || command === "qa-run") && flags.subtype === null) {
2828
2869
  flags.subtype = arg;
2829
2870
  }
@@ -3693,6 +3734,90 @@ function runAppWakeupRecordDeliveryCommand(parsed, options) {
3693
3734
  database.close();
3694
3735
  }
3695
3736
  }
3737
+ function runAppAutopilotCommand(parsed, options) {
3738
+ const action = parsed.flags.action;
3739
+ if (action !== "start" && action !== "stop" && action !== "status") {
3740
+ return errorResult("app-autopilot requires an action: start, stop, or status");
3741
+ }
3742
+ const taskName = requireTask(parsed);
3743
+ const database = openRuntimeDatabase(parsed, options);
3744
+ try {
3745
+ const timestamp = nowIsoSeconds(options);
3746
+ const dbPath = runtimeDbPath(parsed, options);
3747
+ const dispatcherId = parsed.flags.dispatcherId ?? "dispatch-local";
3748
+ const desiredState = action === "start" ? "active" : action === "stop" ? "stopped" : null;
3749
+ let plan = appAutopilotPlanSync(database, {
3750
+ dbPath,
3751
+ dispatchIntervalSeconds: parsed.flags.intervalSeconds,
3752
+ dispatcherId,
3753
+ desiredState,
3754
+ heartbeatIntervalMinutes: 2,
3755
+ heartbeatStaleSeconds: parsed.flags.appStaleAfterSeconds,
3756
+ now: timestamp,
3757
+ quietAfterCycles: parsed.flags.quietAfterCycles,
3758
+ taskName,
3759
+ watchIterations: parsed.flags.watchIterations ?? 1000000,
3760
+ });
3761
+ let receipt = null;
3762
+ if (action === "start" || action === "stop") {
3763
+ const eventType = action === "start" ? "app_autopilot_started" : "app_autopilot_stopped";
3764
+ const eventId = emitTelemetrySync(database, {
3765
+ actor: "operator",
3766
+ attributes: {
3767
+ automation_specs: plan.automation_specs.map((spec) => ({
3768
+ can_create: spec.can_create,
3769
+ interval_minutes: spec.interval_minutes,
3770
+ name: spec.name,
3771
+ role: spec.role,
3772
+ target_thread_id: spec.target_thread_id,
3773
+ target_thread_title: spec.target_thread_title,
3774
+ })),
3775
+ desired_state: desiredState,
3776
+ dispatcher_command: plan.control.dispatcher_command,
3777
+ dispatcher_id: dispatcherId,
3778
+ interval_minutes: plan.interval_minutes,
3779
+ quiescence: plan.quiescence,
3780
+ summary: plan.summary,
3781
+ },
3782
+ correlation: {
3783
+ action,
3784
+ command: "app-autopilot",
3785
+ dispatcher_id: dispatcherId,
3786
+ },
3787
+ eventType,
3788
+ severity: plan.summary.blocked_automations > 0 ? "warning" : "info",
3789
+ summary: `App autopilot ${action} for ${plan.task.name}.`,
3790
+ taskId: plan.task.id,
3791
+ timestamp,
3792
+ });
3793
+ receipt = {
3794
+ event_id: eventId,
3795
+ event_type: eventType,
3796
+ recorded_at: timestamp,
3797
+ };
3798
+ plan = {
3799
+ ...plan,
3800
+ last_policy_event: receipt,
3801
+ };
3802
+ }
3803
+ const output = {
3804
+ action,
3805
+ plan,
3806
+ receipt,
3807
+ };
3808
+ if (parsed.flags.json) {
3809
+ return jsonResult(output);
3810
+ }
3811
+ return {
3812
+ exitCode: 0,
3813
+ handled: true,
3814
+ stdout: renderAppAutopilotText(output),
3815
+ };
3816
+ }
3817
+ finally {
3818
+ database.close();
3819
+ }
3820
+ }
3696
3821
  function parseAppWakeupDeliveryStatus(value) {
3697
3822
  if (value === "sent" || value === "skipped" || value === "blocked") {
3698
3823
  return value;
@@ -3803,6 +3928,39 @@ function renderAppWakeupPlanText(plan) {
3803
3928
  }
3804
3929
  return `${lines.join("\n")}\n`;
3805
3930
  }
3931
+ function renderAppAutopilotText(result) {
3932
+ const lines = [
3933
+ `App autopilot ${result.action} for ${result.plan.task.name}: ${result.plan.desired_state}`,
3934
+ `Loop status: ${result.plan.status.ok ? "ok" : "attention required"}`,
3935
+ `Dispatch: ${result.plan.dispatcher.state}${result.plan.dispatcher.required ? " required" : ""}`,
3936
+ `Dispatch command: ${result.plan.control.dispatcher_command}`,
3937
+ `Wake dispatch: ${result.plan.control.wakeup_dispatch_command}`,
3938
+ ];
3939
+ if (result.plan.quiescence.recommended_action === "stop_autopilot") {
3940
+ lines.push(`Quiescence: stop recommended - ${result.plan.quiescence.reason}`);
3941
+ lines.push(`Stop command: ${result.plan.control.stop_command}`);
3942
+ }
3943
+ else {
3944
+ lines.push(`Quiescence: ${result.plan.quiescence.state} (${result.plan.quiescence.quiet_cycles}/${result.plan.quiescence.threshold_cycles} quiet cycles)`);
3945
+ }
3946
+ if (result.receipt) {
3947
+ lines.push(`Receipt: ${result.receipt.event_type} ${result.receipt.event_id}`);
3948
+ }
3949
+ else if (result.plan.last_policy_event) {
3950
+ lines.push(`Last policy: ${result.plan.last_policy_event.event_type} ${result.plan.last_policy_event.event_id}`);
3951
+ }
3952
+ else {
3953
+ lines.push("Last policy: unconfigured");
3954
+ }
3955
+ for (const spec of result.plan.automation_specs) {
3956
+ lines.push(`${spec.role} automation: ${spec.can_create ? "ready" : "blocked"} ${spec.name}`, ` thread: ${spec.target_thread_title ?? "(untitled)"} ${spec.target_thread_id ?? "(missing)"}`, ` schedule: ${spec.rrule}`);
3957
+ if (spec.blocker) {
3958
+ lines.push(` blocker: ${spec.blocker}`);
3959
+ }
3960
+ }
3961
+ lines.push(result.plan.control.note);
3962
+ return `${lines.join("\n")}\n`;
3963
+ }
3806
3964
  function boundAppSessionForRoleSync(database, options) {
3807
3965
  const sessionJoin = options.role === "manager" ? "manager_session_id" : "worker_session_id";
3808
3966
  const row = database.prepare(`
@@ -14099,6 +14257,7 @@ function isDefaultRuntimeCommand(command) {
14099
14257
  || command === "app-wakeup-plan"
14100
14258
  || command === "app-wakeup-dispatch"
14101
14259
  || command === "app-wakeup-record-delivery"
14260
+ || command === "app-autopilot"
14102
14261
  || command === "loop-templates"
14103
14262
  || command === "loop-triggers"
14104
14263
  || command === "ralph-loop-presets"
@@ -17084,11 +17243,17 @@ function disposableWorkerHandoff(taskName, runName, dbPath) {
17084
17243
  const loopClause = runName
17085
17244
  ? ` for Ralph loop run ${runName}`
17086
17245
  : " for this disposable no-tmux binding";
17246
+ const notifyCommand = durableWorkerNotifyManagerCommand(taskName, dbPath);
17247
+ const dispatchCommand = durableWorkerNotifyDispatchCommand(dbPath);
17087
17248
  return [
17088
17249
  "Use the manage-codex-workers skill.",
17089
17250
  "",
17090
17251
  `You are the worker for task ${taskName}${loopClause}.`,
17091
17252
  "Keep polling your Conveyor worker inbox until there are no items left or the loop reaches max_iterations. Consume the next item now, treat each consumed item as the manager's next instruction, complete the requested work, and report changed files, exact commands run, evidence, and any residual risk.",
17253
+ "After completing or blocking on a consumed item, send the manager a durable Conveyor notification before your final answer. A direct app-thread final answer is not a manager receipt and is not task completion.",
17254
+ `Run: ${notifyCommand}`,
17255
+ `Then run: ${dispatchCommand}`,
17256
+ "If either notify/dispatch command fails, include that failure as the blocker and do not claim the manager was notified.",
17092
17257
  "",
17093
17258
  "Because this is a pull-required Codex app/no-tmux session, autonomous operation requires a heartbeat/wake layer that repeats this worker inbox poll while the thread is idle. If no heartbeat automation is available, report the loop as manual-poll only.",
17094
17259
  "Do not delete, pause, or disable heartbeat automation just because an inbox poll is idle; the manager or operator owns terminal loop teardown.",
@@ -17129,6 +17294,8 @@ function disposableHeartbeatRecommendations(taskName, dbPath) {
17129
17294
  const workerHeartbeatCommand = disposableAppHeartbeatCommand("worker", taskName, dbPath);
17130
17295
  const managerInboxCommand = sessionPollCommand("manager", taskName, dbPath);
17131
17296
  const workerInboxCommand = sessionPollCommand("worker", taskName, dbPath);
17297
+ const workerNotifyCommand = durableWorkerNotifyManagerCommand(taskName, dbPath);
17298
+ const workerNotifyDispatchCommand = durableWorkerNotifyDispatchCommand(dbPath);
17132
17299
  const wakeupDispatchCommand = `${conveyorPollInvocation()} app-wakeup-dispatch ${shellQuote(taskName)} --path ${shellQuote(dbPath)} --json`;
17133
17300
  const deliveryReceiptCommands = disposableDeliveryReceiptCommands(taskName, dbPath);
17134
17301
  return {
@@ -17182,6 +17349,10 @@ function disposableHeartbeatRecommendations(taskName, dbPath) {
17182
17349
  `Run: ${workerHeartbeatCommand}`,
17183
17350
  `If the heartbeat output asks for direct inbox polling, run: ${workerInboxCommand}`,
17184
17351
  "If an item is consumed, execute only that single worker instruction and return exact commands, compact evidence for any completion claim, blockers/residual risk, and exactly one next recommended worker task.",
17352
+ "Before your final answer after any consumed item, notify the manager durably; a direct app-thread final answer is not a manager receipt and is not task completion.",
17353
+ `Run: ${workerNotifyCommand}`,
17354
+ `Then run: ${workerNotifyDispatchCommand}`,
17355
+ "If either notify/dispatch command fails, include that failure as the blocker and do not claim the manager was notified.",
17185
17356
  "If no item is consumed, stop after a one-line idle receipt.",
17186
17357
  "Do not delete, pause, or disable worker heartbeat automation after an idle poll; the manager or operator owns terminal loop teardown.",
17187
17358
  ].join("\n"),
@@ -17201,6 +17372,12 @@ function disposableDeliveryReceiptCommands(taskName, dbPath) {
17201
17372
  function disposableAppHeartbeatCommand(role, taskName, dbPath) {
17202
17373
  return `${conveyorPollInvocation()} app-heartbeat ${shellQuote(taskName)} --role ${role} --path ${shellQuote(dbPath)} --json`;
17203
17374
  }
17375
+ function durableWorkerNotifyManagerCommand(taskName, dbPath) {
17376
+ return `${conveyorPollInvocation()} enqueue-notify-manager ${shellQuote(taskName)} --message ${shellQuote("<compact completion/blocker report with files, commands, evidence, residual risk, and next recommended worker task>")} --correlation-id ${shellQuote("<worker-result-id>")} --path ${shellQuote(dbPath)} --json`;
17377
+ }
17378
+ function durableWorkerNotifyDispatchCommand(dbPath) {
17379
+ return `${conveyorPollInvocation()} dispatch --watch --watch-iterations 1 --interval 2 --dispatcher-id dispatch-local --path ${shellQuote(dbPath)} --json`;
17380
+ }
17204
17381
  function sessionPollCommand(role, taskName, dbPath) {
17205
17382
  const inbox = role === "worker" ? "worker-inbox" : "manager-inbox";
17206
17383
  const task = taskName ? shellQuote(taskName) : "<task>";