agent-conveyor 0.1.10 → 0.1.12

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
@@ -372,9 +372,13 @@ tmux attach -t codex-live-test
372
372
  `heartbeat_recommendations` with role-specific poll prompts; Dispatch can
373
373
  deliver into those inboxes, but a heartbeat or operator wake-up is still
374
374
  required to make an idle app thread poll autonomously. Those recommendations
375
- include a `teardown_policy`: an idle poll is only a quiet interval, not a
376
- reason to delete or pause heartbeat automation; heartbeat teardown belongs to
377
- the manager/operator after terminal closeout or explicit operator instruction.
375
+ also include `wakeup_dispatch_command` and `delivery_receipt_commands` for
376
+ app-thread wake recovery. Use them to record sent, skipped, and blocked wake
377
+ outcomes after `app-wakeup-dispatch`; an app-thread send is not task
378
+ completion. The recommendations include a `teardown_policy`: an idle poll is
379
+ only a quiet interval, not a reason to delete or pause heartbeat automation;
380
+ heartbeat teardown belongs to the manager/operator after terminal closeout or
381
+ explicit operator instruction.
378
382
  The optional
379
383
  Codex app thread metadata is normally supplied after a Codex app manager has
380
384
  used `create_thread` and `set_thread_title`; terminal-only users can omit it
@@ -400,6 +404,15 @@ tmux attach -t codex-live-test
400
404
  `sent` is accepted only when the referenced `app-wakeup-dispatch` receipt has
401
405
  a matching `ready_to_send` action with `send_ready=true` and the same thread
402
406
  id; healthy and blocked roles must be recorded as `skipped` or `blocked`.
407
+ - `app-autopilot start|stop|status TASK [--dispatcher-id ID]
408
+ [--interval SECONDS] [--watch-iterations N] [--stale-after N] [--json]` —
409
+ Manage the pair-level app-native heartbeat policy for the active
410
+ manager/worker binding. `start` and `stop` write telemetry receipts and emit
411
+ the exact manager/worker Codex app heartbeat automation specs plus the
412
+ bounded Dispatch watch command. A plain shell CLI cannot call Codex app
413
+ thread tools, so create/pause those heartbeat automations from a Codex app
414
+ operator session using the emitted specs; Conveyor remains the durable source
415
+ of truth through Dispatch, inboxes, wake receipts, and app heartbeat status.
403
416
  - `discover [QUERY] [--all] [--limit N]` / `search [QUERY]` — Search tasks,
404
417
  registered sessions, active bindings, and recent telemetry in one JSON result.
405
418
  Use this for conversational setup when a manager or Codex session needs to
@@ -1045,10 +1058,12 @@ Current dispatch state:
1045
1058
  reaches `max_iterations`. For no-tmux Codex app sessions, treat
1046
1059
  `communication.requires_polling=true` as requiring a heartbeat/wake layer:
1047
1060
  a delivered pull inbox item does not by itself wake an idle app thread. Do
1048
- not delete or pause heartbeats because an inbox poll is idle. A terminal
1049
- manager decision should be followed by `finish-task --require-criteria-audit`
1050
- or by an explicit blocker explaining why the task/binding still appears
1051
- active.
1061
+ not delete or pause heartbeats because an inbox poll is idle. Use generated
1062
+ `heartbeat_recommendations.wakeup_dispatch_command` and
1063
+ `heartbeat_recommendations.delivery_receipt_commands` for stale-thread wake
1064
+ recovery receipts. A terminal manager decision should be followed by
1065
+ `finish-task --require-criteria-audit` or by an explicit blocker explaining
1066
+ why the task/binding still appears active.
1052
1067
  - `register-worker`, `register-manager`, `sessions`, `discover`, and
1053
1068
  `create-disposable-binding --json` expose a `communication` block per
1054
1069
  session. Treat `session_kind='tmux'` plus `receive_style='push'` as direct
@@ -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] ${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
  "",
@@ -1496,7 +1510,8 @@ function parseRuntimeArgs(args, env) {
1496
1510
  && command !== "app-heartbeat"
1497
1511
  && command !== "app-loop-status"
1498
1512
  && command !== "app-wakeup-plan"
1499
- && command !== "app-wakeup-dispatch") {
1513
+ && command !== "app-wakeup-dispatch"
1514
+ && command !== "app-autopilot") {
1500
1515
  return { command, enabled, error: "Unsupported TypeScript runtime option: --dispatcher-id", explicit, flags, task };
1501
1516
  }
1502
1517
  const value = valueAfter(queue, index, arg);
@@ -2463,7 +2478,11 @@ function parseRuntimeArgs(args, env) {
2463
2478
  index += 1;
2464
2479
  }
2465
2480
  else if (arg === "--stale-after") {
2466
- if (command !== "app-heartbeat" && command !== "app-loop-status" && command !== "app-wakeup-plan" && command !== "app-wakeup-dispatch") {
2481
+ if (command !== "app-heartbeat"
2482
+ && command !== "app-loop-status"
2483
+ && command !== "app-wakeup-plan"
2484
+ && command !== "app-wakeup-dispatch"
2485
+ && command !== "app-autopilot") {
2467
2486
  return { command, enabled, error: "Unsupported TypeScript runtime option: --stale-after", explicit, flags, task };
2468
2487
  }
2469
2488
  const parsedValue = valueAfter(queue, index, arg);
@@ -2676,7 +2695,7 @@ function parseRuntimeArgs(args, env) {
2676
2695
  index += 1;
2677
2696
  }
2678
2697
  else if (arg === "--interval") {
2679
- if (command !== "dispatch" && command !== "session-inbox" && command !== "manager-inbox" && command !== "worker-inbox") {
2698
+ if (command !== "dispatch" && command !== "session-inbox" && command !== "manager-inbox" && command !== "worker-inbox" && command !== "app-autopilot") {
2680
2699
  return { command, enabled, error: "Unsupported TypeScript runtime option: --interval", explicit, flags, task };
2681
2700
  }
2682
2701
  const parsedValue = valueAfter(queue, index, arg);
@@ -2691,7 +2710,7 @@ function parseRuntimeArgs(args, env) {
2691
2710
  index += 1;
2692
2711
  }
2693
2712
  else if (arg === "--watch-iterations") {
2694
- if (command !== "dispatch") {
2713
+ if (command !== "dispatch" && command !== "app-autopilot") {
2695
2714
  return { command, enabled, error: "Unsupported TypeScript runtime option: --watch-iterations", explicit, flags, task };
2696
2715
  }
2697
2716
  const parsedValue = valueAfter(queue, index, arg);
@@ -2824,6 +2843,12 @@ function parseRuntimeArgs(args, env) {
2824
2843
  }
2825
2844
  flags.subtype = arg;
2826
2845
  }
2846
+ else if (command === "app-autopilot" && flags.action === null) {
2847
+ if (!["start", "stop", "status"].includes(arg)) {
2848
+ return { command, enabled, error: `Unsupported app-autopilot action: ${arg}`, explicit, flags, task };
2849
+ }
2850
+ flags.action = arg;
2851
+ }
2827
2852
  else if ((command === "qa-plan" || command === "qa-run") && flags.subtype === null) {
2828
2853
  flags.subtype = arg;
2829
2854
  }
@@ -3693,6 +3718,88 @@ function runAppWakeupRecordDeliveryCommand(parsed, options) {
3693
3718
  database.close();
3694
3719
  }
3695
3720
  }
3721
+ function runAppAutopilotCommand(parsed, options) {
3722
+ const action = parsed.flags.action;
3723
+ if (action !== "start" && action !== "stop" && action !== "status") {
3724
+ return errorResult("app-autopilot requires an action: start, stop, or status");
3725
+ }
3726
+ const taskName = requireTask(parsed);
3727
+ const database = openRuntimeDatabase(parsed, options);
3728
+ try {
3729
+ const timestamp = nowIsoSeconds(options);
3730
+ const dbPath = runtimeDbPath(parsed, options);
3731
+ const dispatcherId = parsed.flags.dispatcherId ?? "dispatch-local";
3732
+ const desiredState = action === "start" ? "active" : action === "stop" ? "stopped" : null;
3733
+ let plan = appAutopilotPlanSync(database, {
3734
+ dbPath,
3735
+ dispatchIntervalSeconds: parsed.flags.intervalSeconds,
3736
+ dispatcherId,
3737
+ desiredState,
3738
+ heartbeatIntervalMinutes: 2,
3739
+ heartbeatStaleSeconds: parsed.flags.appStaleAfterSeconds,
3740
+ now: timestamp,
3741
+ taskName,
3742
+ watchIterations: parsed.flags.watchIterations ?? 1000000,
3743
+ });
3744
+ let receipt = null;
3745
+ if (action === "start" || action === "stop") {
3746
+ const eventType = action === "start" ? "app_autopilot_started" : "app_autopilot_stopped";
3747
+ const eventId = emitTelemetrySync(database, {
3748
+ actor: "operator",
3749
+ attributes: {
3750
+ automation_specs: plan.automation_specs.map((spec) => ({
3751
+ can_create: spec.can_create,
3752
+ interval_minutes: spec.interval_minutes,
3753
+ name: spec.name,
3754
+ role: spec.role,
3755
+ target_thread_id: spec.target_thread_id,
3756
+ target_thread_title: spec.target_thread_title,
3757
+ })),
3758
+ desired_state: desiredState,
3759
+ dispatcher_command: plan.control.dispatcher_command,
3760
+ dispatcher_id: dispatcherId,
3761
+ interval_minutes: plan.interval_minutes,
3762
+ summary: plan.summary,
3763
+ },
3764
+ correlation: {
3765
+ action,
3766
+ command: "app-autopilot",
3767
+ dispatcher_id: dispatcherId,
3768
+ },
3769
+ eventType,
3770
+ severity: plan.summary.blocked_automations > 0 ? "warning" : "info",
3771
+ summary: `App autopilot ${action} for ${plan.task.name}.`,
3772
+ taskId: plan.task.id,
3773
+ timestamp,
3774
+ });
3775
+ receipt = {
3776
+ event_id: eventId,
3777
+ event_type: eventType,
3778
+ recorded_at: timestamp,
3779
+ };
3780
+ plan = {
3781
+ ...plan,
3782
+ last_policy_event: receipt,
3783
+ };
3784
+ }
3785
+ const output = {
3786
+ action,
3787
+ plan,
3788
+ receipt,
3789
+ };
3790
+ if (parsed.flags.json) {
3791
+ return jsonResult(output);
3792
+ }
3793
+ return {
3794
+ exitCode: 0,
3795
+ handled: true,
3796
+ stdout: renderAppAutopilotText(output),
3797
+ };
3798
+ }
3799
+ finally {
3800
+ database.close();
3801
+ }
3802
+ }
3696
3803
  function parseAppWakeupDeliveryStatus(value) {
3697
3804
  if (value === "sent" || value === "skipped" || value === "blocked") {
3698
3805
  return value;
@@ -3803,6 +3910,32 @@ function renderAppWakeupPlanText(plan) {
3803
3910
  }
3804
3911
  return `${lines.join("\n")}\n`;
3805
3912
  }
3913
+ function renderAppAutopilotText(result) {
3914
+ const lines = [
3915
+ `App autopilot ${result.action} for ${result.plan.task.name}: ${result.plan.desired_state}`,
3916
+ `Loop status: ${result.plan.status.ok ? "ok" : "attention required"}`,
3917
+ `Dispatch: ${result.plan.dispatcher.state}${result.plan.dispatcher.required ? " required" : ""}`,
3918
+ `Dispatch command: ${result.plan.control.dispatcher_command}`,
3919
+ `Wake dispatch: ${result.plan.control.wakeup_dispatch_command}`,
3920
+ ];
3921
+ if (result.receipt) {
3922
+ lines.push(`Receipt: ${result.receipt.event_type} ${result.receipt.event_id}`);
3923
+ }
3924
+ else if (result.plan.last_policy_event) {
3925
+ lines.push(`Last policy: ${result.plan.last_policy_event.event_type} ${result.plan.last_policy_event.event_id}`);
3926
+ }
3927
+ else {
3928
+ lines.push("Last policy: unconfigured");
3929
+ }
3930
+ for (const spec of result.plan.automation_specs) {
3931
+ 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}`);
3932
+ if (spec.blocker) {
3933
+ lines.push(` blocker: ${spec.blocker}`);
3934
+ }
3935
+ }
3936
+ lines.push(result.plan.control.note);
3937
+ return `${lines.join("\n")}\n`;
3938
+ }
3806
3939
  function boundAppSessionForRoleSync(database, options) {
3807
3940
  const sessionJoin = options.role === "manager" ? "manager_session_id" : "worker_session_id";
3808
3941
  const row = database.prepare(`
@@ -14099,6 +14232,7 @@ function isDefaultRuntimeCommand(command) {
14099
14232
  || command === "app-wakeup-plan"
14100
14233
  || command === "app-wakeup-dispatch"
14101
14234
  || command === "app-wakeup-record-delivery"
14235
+ || command === "app-autopilot"
14102
14236
  || command === "loop-templates"
14103
14237
  || command === "loop-triggers"
14104
14238
  || command === "ralph-loop-presets"
@@ -17114,6 +17248,8 @@ function renderDisposableBindingText(result) {
17114
17248
  lines.push(` worker: ${result.heartbeat_recommendations.worker.poll_command}`);
17115
17249
  lines.push(` status: ${result.heartbeat_recommendations.status_command}`);
17116
17250
  lines.push(` wakeup plan: ${result.heartbeat_recommendations.wakeup_plan_command}`);
17251
+ lines.push(` wakeup dispatch: ${result.heartbeat_recommendations.wakeup_dispatch_command}`);
17252
+ lines.push(` delivery sent: ${result.heartbeat_recommendations.delivery_receipt_commands.sent}`);
17117
17253
  lines.push(` teardown: ${result.heartbeat_recommendations.teardown_policy.idle_poll}`);
17118
17254
  lines.push(` closeout: ${result.heartbeat_recommendations.teardown_policy.terminal_closeout_command}`);
17119
17255
  }
@@ -17127,6 +17263,8 @@ function disposableHeartbeatRecommendations(taskName, dbPath) {
17127
17263
  const workerHeartbeatCommand = disposableAppHeartbeatCommand("worker", taskName, dbPath);
17128
17264
  const managerInboxCommand = sessionPollCommand("manager", taskName, dbPath);
17129
17265
  const workerInboxCommand = sessionPollCommand("worker", taskName, dbPath);
17266
+ const wakeupDispatchCommand = `${conveyorPollInvocation()} app-wakeup-dispatch ${shellQuote(taskName)} --path ${shellQuote(dbPath)} --json`;
17267
+ const deliveryReceiptCommands = disposableDeliveryReceiptCommands(taskName, dbPath);
17130
17268
  return {
17131
17269
  applies_when: {
17132
17270
  can_receive_push: false,
@@ -17134,6 +17272,7 @@ function disposableHeartbeatRecommendations(taskName, dbPath) {
17134
17272
  receive_style: "pull",
17135
17273
  session_kind: "codex_app",
17136
17274
  },
17275
+ delivery_receipt_commands: deliveryReceiptCommands,
17137
17276
  interval_minutes: 2,
17138
17277
  note: "Dispatch can deliver pull-required inbox items, but Codex app/no-tmux sessions still need a heartbeat or operator wake-up to poll while idle.",
17139
17278
  status_command: `${conveyorPollInvocation()} app-loop-status ${shellQuote(taskName)} --path ${shellQuote(dbPath)} --json`,
@@ -17144,6 +17283,7 @@ function disposableHeartbeatRecommendations(taskName, dbPath) {
17144
17283
  terminal_closeout_command: terminalCloseoutCommand,
17145
17284
  worker_rule: "The worker must not own loop teardown and must not remove heartbeat automation based on idle polling.",
17146
17285
  },
17286
+ wakeup_dispatch_command: wakeupDispatchCommand,
17147
17287
  wakeup_plan_command: `${conveyorPollInvocation()} app-wakeup-plan ${shellQuote(taskName)} --path ${shellQuote(dbPath)} --json`,
17148
17288
  manager: {
17149
17289
  direct_inbox_command: managerInboxCommand,
@@ -17154,6 +17294,11 @@ function disposableHeartbeatRecommendations(taskName, dbPath) {
17154
17294
  `Run the manager app heartbeat for task ${taskName}.`,
17155
17295
  `Run: ${managerHeartbeatCommand}`,
17156
17296
  `If the heartbeat output asks for direct inbox polling, run: ${managerInboxCommand}`,
17297
+ `For stale app-thread recovery with an auditable receipt, run: ${wakeupDispatchCommand}`,
17298
+ "Send app-thread wake prompts only for actions where `send_ready=true`; direct app-thread delivery is not task completion.",
17299
+ `After a successful app-thread send, record it with: ${deliveryReceiptCommands.sent}`,
17300
+ `For healthy skipped actions, record: ${deliveryReceiptCommands.skipped}`,
17301
+ `For missing-thread blocked actions, record: ${deliveryReceiptCommands.blocked}`,
17157
17302
  "If an item is consumed, execute only that manager instruction, verify worker claims before recording conclusions, update Conveyor state as appropriate, and produce exactly one next worker task.",
17158
17303
  "If no item is consumed, stop after a one-line idle receipt.",
17159
17304
  "Do not delete, pause, or disable manager or worker heartbeat automation after an idle poll; an idle poll is only a quiet interval.",
@@ -17170,13 +17315,23 @@ function disposableHeartbeatRecommendations(taskName, dbPath) {
17170
17315
  `Run the worker app heartbeat for task ${taskName}.`,
17171
17316
  `Run: ${workerHeartbeatCommand}`,
17172
17317
  `If the heartbeat output asks for direct inbox polling, run: ${workerInboxCommand}`,
17173
- "If an item is consumed, execute only that single worker instruction and return exact commands, compact evidence, blockers/residual risk, and exactly one next recommended worker task.",
17318
+ "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.",
17174
17319
  "If no item is consumed, stop after a one-line idle receipt.",
17175
17320
  "Do not delete, pause, or disable worker heartbeat automation after an idle poll; the manager or operator owns terminal loop teardown.",
17176
17321
  ].join("\n"),
17177
17322
  },
17178
17323
  };
17179
17324
  }
17325
+ function disposableDeliveryReceiptCommands(taskName, dbPath) {
17326
+ const base = `${conveyorPollInvocation()} app-wakeup-record-delivery ${shellQuote(taskName)} --role <role> --dispatch-receipt <receipt.event_id>`;
17327
+ const pathAndJson = ` --path ${shellQuote(dbPath)} --json`;
17328
+ return {
17329
+ blocked: `${base} --delivery-status blocked${pathAndJson}`,
17330
+ note: "Run these only after app-wakeup-dispatch. Replace <role>, <receipt.event_id>, and <action.thread.id> from the dispatch JSON; sent is valid only for send_ready=true actions.",
17331
+ sent: `${base} --delivery-status sent --thread-id <action.thread.id>${pathAndJson}`,
17332
+ skipped: `${base} --delivery-status skipped${pathAndJson}`,
17333
+ };
17334
+ }
17180
17335
  function disposableAppHeartbeatCommand(role, taskName, dbPath) {
17181
17336
  return `${conveyorPollInvocation()} app-heartbeat ${shellQuote(taskName)} --role ${role} --path ${shellQuote(dbPath)} --json`;
17182
17337
  }