agent-conveyor 0.1.8 → 0.1.10
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 +26 -4
- package/dist/cli/typescript-runtime.js +500 -8
- package/dist/cli/typescript-runtime.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime/app-autonomy.d.ts +109 -0
- package/dist/runtime/app-autonomy.js +263 -0
- package/dist/runtime/app-autonomy.js.map +1 -0
- package/docs/manager-recipes.md +41 -6
- package/package.json +1 -1
- package/skills/manage-codex-workers/SKILL.md +53 -24
package/README.md
CHANGED
|
@@ -379,6 +379,27 @@ tmux attach -t codex-live-test
|
|
|
379
379
|
Codex app thread metadata is normally supplied after a Codex app manager has
|
|
380
380
|
used `create_thread` and `set_thread_title`; terminal-only users can omit it
|
|
381
381
|
and still use the manual no-tmux handoff.
|
|
382
|
+
- `app-heartbeat TASK --role manager|worker [--dispatcher-id ID]
|
|
383
|
+
[--stale-after N] [--json]` — Record a Codex app/no-tmux manager or worker
|
|
384
|
+
heartbeat for the active binding and return that role's next poll commands.
|
|
385
|
+
Use this as the recurring heartbeat command for pull-required app sessions.
|
|
386
|
+
- `app-loop-status TASK [--dispatcher-id ID] [--stale-after N] [--json]` —
|
|
387
|
+
Summarize manager lease, worker lease, Dispatch heartbeat, and next required
|
|
388
|
+
actions for an app-native Conveyor loop.
|
|
389
|
+
- `app-wakeup-plan TASK [--dispatcher-id ID] [--stale-after N] [--json]` —
|
|
390
|
+
Emit exact manager and worker thread wake prompts, including app thread ids
|
|
391
|
+
and titles when present, for an operator or Codex app automation to send.
|
|
392
|
+
- `app-wakeup-dispatch TASK [--dispatcher-id ID] [--stale-after N] [--json]` —
|
|
393
|
+
Prepare adapter-ready Codex app wake actions and record a telemetry receipt
|
|
394
|
+
for prepared, skipped, and blocked role wakeups. This command does not send
|
|
395
|
+
`send_message_to_thread`; the Codex app/operator layer sends prompts whose
|
|
396
|
+
actions report `send_ready=true`.
|
|
397
|
+
- `app-wakeup-record-delivery TASK --role manager|worker --dispatch-receipt ID
|
|
398
|
+
--delivery-status sent|skipped|blocked [--thread-id ID] [--reason TEXT]
|
|
399
|
+
[--json]` — Record what the Codex app/operator layer did with a wake action.
|
|
400
|
+
`sent` is accepted only when the referenced `app-wakeup-dispatch` receipt has
|
|
401
|
+
a matching `ready_to_send` action with `send_ready=true` and the same thread
|
|
402
|
+
id; healthy and blocked roles must be recorded as `skipped` or `blocked`.
|
|
382
403
|
- `discover [QUERY] [--all] [--limit N]` / `search [QUERY]` — Search tasks,
|
|
383
404
|
registered sessions, active bindings, and recent telemetry in one JSON result.
|
|
384
405
|
Use this for conversational setup when a manager or Codex session needs to
|
|
@@ -1017,10 +1038,11 @@ Current dispatch state:
|
|
|
1017
1038
|
in `routed_notifications`, and threaded with `correlation_id`.
|
|
1018
1039
|
- The session inbox is the same `routed_notifications` stream addressed by
|
|
1019
1040
|
`target_session_id`: tmux push is optional transport. Codex app-based sessions
|
|
1020
|
-
should
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1041
|
+
should run `app-heartbeat` as their recurring heartbeat, using the direct
|
|
1042
|
+
`communication.poll_command` only as a fallback or when returned in heartbeat
|
|
1043
|
+
guidance. For disposable Ralph loops, use the generated `worker_handoff`
|
|
1044
|
+
prompt so the worker keeps polling until no inbox item remains or the loop
|
|
1045
|
+
reaches `max_iterations`. For no-tmux Codex app sessions, treat
|
|
1024
1046
|
`communication.requires_polling=true` as requiring a heartbeat/wake layer:
|
|
1025
1047
|
a delivered pull inbox item does not by itself wake an idle app thread. Do
|
|
1026
1048
|
not delete or pause heartbeats because an inbox poll is idle. A terminal
|
|
@@ -5,6 +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
9
|
import { classifyBusyWait, classifyStartupOutput } from "../runtime/classify.js";
|
|
9
10
|
import { exportTaskSync } from "../runtime/export.js";
|
|
10
11
|
import { ingestSessionSync } from "../runtime/ingest.js";
|
|
@@ -118,6 +119,21 @@ export function runTypescriptRuntimeCommand(options) {
|
|
|
118
119
|
if (parsed.command === "loop-status") {
|
|
119
120
|
return runLoopStatusCommand(parsed, options);
|
|
120
121
|
}
|
|
122
|
+
if (parsed.command === "app-heartbeat") {
|
|
123
|
+
return runAppHeartbeatCommand(parsed, options);
|
|
124
|
+
}
|
|
125
|
+
if (parsed.command === "app-loop-status") {
|
|
126
|
+
return runAppLoopStatusCommand(parsed, options);
|
|
127
|
+
}
|
|
128
|
+
if (parsed.command === "app-wakeup-plan") {
|
|
129
|
+
return runAppWakeupPlanCommand(parsed, options);
|
|
130
|
+
}
|
|
131
|
+
if (parsed.command === "app-wakeup-dispatch") {
|
|
132
|
+
return runAppWakeupDispatchCommand(parsed, options);
|
|
133
|
+
}
|
|
134
|
+
if (parsed.command === "app-wakeup-record-delivery") {
|
|
135
|
+
return runAppWakeupRecordDeliveryCommand(parsed, options);
|
|
136
|
+
}
|
|
121
137
|
if (parsed.command === "qa-plan") {
|
|
122
138
|
return runQaPlanCommand(parsed);
|
|
123
139
|
}
|
|
@@ -417,6 +433,7 @@ function parseRuntimeArgs(args, env) {
|
|
|
417
433
|
includeLegacy: false,
|
|
418
434
|
help: false,
|
|
419
435
|
redactIdentityToken: false,
|
|
436
|
+
appStaleAfterSeconds: 180,
|
|
420
437
|
active: false,
|
|
421
438
|
add: false,
|
|
422
439
|
apply: false,
|
|
@@ -532,9 +549,12 @@ function parseRuntimeArgs(args, env) {
|
|
|
532
549
|
captureTranscriptLines: DEFAULT_HISTORY_LINES,
|
|
533
550
|
captureTranscriptMode: "segment",
|
|
534
551
|
decisionId: null,
|
|
552
|
+
deliveryStatus: null,
|
|
553
|
+
dispatchReceipt: null,
|
|
535
554
|
force: false,
|
|
536
555
|
message: null,
|
|
537
556
|
reason: null,
|
|
557
|
+
threadId: null,
|
|
538
558
|
consumeNext: false,
|
|
539
559
|
wait: false,
|
|
540
560
|
requireAcks: false,
|
|
@@ -1469,7 +1489,14 @@ function parseRuntimeArgs(args, env) {
|
|
|
1469
1489
|
index += 1;
|
|
1470
1490
|
}
|
|
1471
1491
|
else if (arg === "--dispatcher-id") {
|
|
1472
|
-
if (command !== "pair"
|
|
1492
|
+
if (command !== "pair"
|
|
1493
|
+
&& command !== "dispatch"
|
|
1494
|
+
&& command !== "qa-run"
|
|
1495
|
+
&& command !== "dashboard"
|
|
1496
|
+
&& command !== "app-heartbeat"
|
|
1497
|
+
&& command !== "app-loop-status"
|
|
1498
|
+
&& command !== "app-wakeup-plan"
|
|
1499
|
+
&& command !== "app-wakeup-dispatch") {
|
|
1473
1500
|
return { command, enabled, error: "Unsupported TypeScript runtime option: --dispatcher-id", explicit, flags, task };
|
|
1474
1501
|
}
|
|
1475
1502
|
const value = valueAfter(queue, index, arg);
|
|
@@ -1812,7 +1839,7 @@ function parseRuntimeArgs(args, env) {
|
|
|
1812
1839
|
index = queue.length;
|
|
1813
1840
|
}
|
|
1814
1841
|
else if (arg === "--reason") {
|
|
1815
|
-
if (command !== "finish-task" && command !== "stop-task" && command !== "record-decision" && command !== "compact-worker") {
|
|
1842
|
+
if (command !== "finish-task" && command !== "stop-task" && command !== "record-decision" && command !== "compact-worker" && command !== "app-wakeup-record-delivery") {
|
|
1816
1843
|
return { command, enabled, error: "Unsupported TypeScript runtime option: --reason", explicit, flags, task };
|
|
1817
1844
|
}
|
|
1818
1845
|
const value = valueAfter(queue, index, arg);
|
|
@@ -1822,6 +1849,39 @@ function parseRuntimeArgs(args, env) {
|
|
|
1822
1849
|
flags.reason = value.value;
|
|
1823
1850
|
index += 1;
|
|
1824
1851
|
}
|
|
1852
|
+
else if (arg === "--dispatch-receipt") {
|
|
1853
|
+
if (command !== "app-wakeup-record-delivery") {
|
|
1854
|
+
return { command, enabled, error: "Unsupported TypeScript runtime option: --dispatch-receipt", explicit, flags, task };
|
|
1855
|
+
}
|
|
1856
|
+
const value = valueAfter(queue, index, arg);
|
|
1857
|
+
if (value.error) {
|
|
1858
|
+
return { command, enabled, error: value.error, explicit, flags, task };
|
|
1859
|
+
}
|
|
1860
|
+
flags.dispatchReceipt = value.value;
|
|
1861
|
+
index += 1;
|
|
1862
|
+
}
|
|
1863
|
+
else if (arg === "--delivery-status") {
|
|
1864
|
+
if (command !== "app-wakeup-record-delivery") {
|
|
1865
|
+
return { command, enabled, error: "Unsupported TypeScript runtime option: --delivery-status", explicit, flags, task };
|
|
1866
|
+
}
|
|
1867
|
+
const value = valueAfter(queue, index, arg);
|
|
1868
|
+
if (value.error) {
|
|
1869
|
+
return { command, enabled, error: value.error, explicit, flags, task };
|
|
1870
|
+
}
|
|
1871
|
+
flags.deliveryStatus = value.value;
|
|
1872
|
+
index += 1;
|
|
1873
|
+
}
|
|
1874
|
+
else if (arg === "--thread-id") {
|
|
1875
|
+
if (command !== "app-wakeup-record-delivery") {
|
|
1876
|
+
return { command, enabled, error: "Unsupported TypeScript runtime option: --thread-id", explicit, flags, task };
|
|
1877
|
+
}
|
|
1878
|
+
const value = valueAfter(queue, index, arg);
|
|
1879
|
+
if (value.error) {
|
|
1880
|
+
return { command, enabled, error: value.error, explicit, flags, task };
|
|
1881
|
+
}
|
|
1882
|
+
flags.threadId = value.value;
|
|
1883
|
+
index += 1;
|
|
1884
|
+
}
|
|
1825
1885
|
else if (arg === "--message") {
|
|
1826
1886
|
if (command !== "finish-task"
|
|
1827
1887
|
&& command !== "stop-task"
|
|
@@ -2402,6 +2462,21 @@ function parseRuntimeArgs(args, env) {
|
|
|
2402
2462
|
flags.statusStaleSeconds = value;
|
|
2403
2463
|
index += 1;
|
|
2404
2464
|
}
|
|
2465
|
+
else if (arg === "--stale-after") {
|
|
2466
|
+
if (command !== "app-heartbeat" && command !== "app-loop-status" && command !== "app-wakeup-plan" && command !== "app-wakeup-dispatch") {
|
|
2467
|
+
return { command, enabled, error: "Unsupported TypeScript runtime option: --stale-after", explicit, flags, task };
|
|
2468
|
+
}
|
|
2469
|
+
const parsedValue = valueAfter(queue, index, arg);
|
|
2470
|
+
if (parsedValue.error) {
|
|
2471
|
+
return { command, enabled, error: parsedValue.error, explicit, flags, task };
|
|
2472
|
+
}
|
|
2473
|
+
const value = Number(parsedValue.value);
|
|
2474
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
2475
|
+
return { command, enabled, error: "--stale-after must be a non-negative number.", explicit, flags, task };
|
|
2476
|
+
}
|
|
2477
|
+
flags.appStaleAfterSeconds = value;
|
|
2478
|
+
index += 1;
|
|
2479
|
+
}
|
|
2405
2480
|
else if (arg === "--terminal-stale-seconds") {
|
|
2406
2481
|
if (command !== "idle-check") {
|
|
2407
2482
|
return { command, enabled, error: "Unsupported TypeScript runtime option: --terminal-stale-seconds", explicit, flags, task };
|
|
@@ -3347,6 +3422,402 @@ function runLoopStatusCommand(parsed, options) {
|
|
|
3347
3422
|
database.close();
|
|
3348
3423
|
}
|
|
3349
3424
|
}
|
|
3425
|
+
function runAppHeartbeatCommand(parsed, options) {
|
|
3426
|
+
const taskName = requireTask(parsed);
|
|
3427
|
+
if (parsed.flags.role !== "manager" && parsed.flags.role !== "worker") {
|
|
3428
|
+
return errorResult("app-heartbeat requires --role manager|worker");
|
|
3429
|
+
}
|
|
3430
|
+
const role = parsed.flags.role;
|
|
3431
|
+
const database = openRuntimeDatabase(parsed, options);
|
|
3432
|
+
try {
|
|
3433
|
+
const task = taskRowForDiagnostics(database, taskName);
|
|
3434
|
+
const session = boundAppSessionForRoleSync(database, { role, taskId: task.id });
|
|
3435
|
+
const timestamp = nowIsoSeconds(options);
|
|
3436
|
+
const dbPath = runtimeDbPath(parsed, options);
|
|
3437
|
+
database.prepare("update sessions set last_heartbeat_at = ? where id = ?").run(timestamp, session.id);
|
|
3438
|
+
emitTelemetrySync(database, {
|
|
3439
|
+
actor: role,
|
|
3440
|
+
attributes: {
|
|
3441
|
+
direct_inbox_command: directInboxPollCommand(role, task.name, dbPath),
|
|
3442
|
+
role,
|
|
3443
|
+
session_id: session.id,
|
|
3444
|
+
task: task.name,
|
|
3445
|
+
},
|
|
3446
|
+
correlation: { command: "app-heartbeat" },
|
|
3447
|
+
eventType: "app_heartbeat",
|
|
3448
|
+
severity: "info",
|
|
3449
|
+
summary: `${role} app heartbeat for ${task.name}.`,
|
|
3450
|
+
taskId: task.id,
|
|
3451
|
+
timestamp,
|
|
3452
|
+
});
|
|
3453
|
+
const status = appLoopStatusSync(database, {
|
|
3454
|
+
dbPath,
|
|
3455
|
+
dispatcherId: parsed.flags.dispatcherId ?? "dispatch-local",
|
|
3456
|
+
heartbeatStaleSeconds: parsed.flags.appStaleAfterSeconds,
|
|
3457
|
+
now: timestamp,
|
|
3458
|
+
taskName: task.name,
|
|
3459
|
+
});
|
|
3460
|
+
const roleStatus = role === "manager" ? status.manager : status.worker;
|
|
3461
|
+
const output = {
|
|
3462
|
+
heartbeat: {
|
|
3463
|
+
recorded_at: timestamp,
|
|
3464
|
+
state: "recorded",
|
|
3465
|
+
},
|
|
3466
|
+
next: {
|
|
3467
|
+
direct_inbox_command: roleStatus.direct_inbox_command,
|
|
3468
|
+
poll_command: roleStatus.poll_command,
|
|
3469
|
+
},
|
|
3470
|
+
role,
|
|
3471
|
+
status,
|
|
3472
|
+
task: { id: task.id, name: task.name },
|
|
3473
|
+
};
|
|
3474
|
+
if (parsed.flags.json) {
|
|
3475
|
+
return jsonResult(output);
|
|
3476
|
+
}
|
|
3477
|
+
return {
|
|
3478
|
+
exitCode: 0,
|
|
3479
|
+
handled: true,
|
|
3480
|
+
stdout: `${role} heartbeat recorded for ${task.name}\nNext: ${roleStatus.direct_inbox_command ?? roleStatus.poll_command ?? "(none)"}\n`,
|
|
3481
|
+
};
|
|
3482
|
+
}
|
|
3483
|
+
finally {
|
|
3484
|
+
database.close();
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
function runAppLoopStatusCommand(parsed, options) {
|
|
3488
|
+
const taskName = requireTask(parsed);
|
|
3489
|
+
const database = openRuntimeDatabase(parsed, options);
|
|
3490
|
+
try {
|
|
3491
|
+
const status = appLoopStatusSync(database, {
|
|
3492
|
+
dbPath: runtimeDbPath(parsed, options),
|
|
3493
|
+
dispatcherId: parsed.flags.dispatcherId ?? "dispatch-local",
|
|
3494
|
+
heartbeatStaleSeconds: parsed.flags.appStaleAfterSeconds,
|
|
3495
|
+
now: nowIsoSeconds(options),
|
|
3496
|
+
taskName,
|
|
3497
|
+
});
|
|
3498
|
+
if (parsed.flags.json) {
|
|
3499
|
+
return jsonResult(status);
|
|
3500
|
+
}
|
|
3501
|
+
return {
|
|
3502
|
+
exitCode: 0,
|
|
3503
|
+
handled: true,
|
|
3504
|
+
stdout: renderAppLoopStatusText(status),
|
|
3505
|
+
};
|
|
3506
|
+
}
|
|
3507
|
+
finally {
|
|
3508
|
+
database.close();
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
function runAppWakeupPlanCommand(parsed, options) {
|
|
3512
|
+
const taskName = requireTask(parsed);
|
|
3513
|
+
const database = openRuntimeDatabase(parsed, options);
|
|
3514
|
+
try {
|
|
3515
|
+
const plan = appWakeupPlanSync(database, {
|
|
3516
|
+
dbPath: runtimeDbPath(parsed, options),
|
|
3517
|
+
dispatcherId: parsed.flags.dispatcherId ?? "dispatch-local",
|
|
3518
|
+
heartbeatStaleSeconds: parsed.flags.appStaleAfterSeconds,
|
|
3519
|
+
now: nowIsoSeconds(options),
|
|
3520
|
+
taskName,
|
|
3521
|
+
});
|
|
3522
|
+
if (parsed.flags.json) {
|
|
3523
|
+
return jsonResult(plan);
|
|
3524
|
+
}
|
|
3525
|
+
return {
|
|
3526
|
+
exitCode: 0,
|
|
3527
|
+
handled: true,
|
|
3528
|
+
stdout: renderAppWakeupPlanText(plan),
|
|
3529
|
+
};
|
|
3530
|
+
}
|
|
3531
|
+
finally {
|
|
3532
|
+
database.close();
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
function runAppWakeupDispatchCommand(parsed, options) {
|
|
3536
|
+
const taskName = requireTask(parsed);
|
|
3537
|
+
const database = openRuntimeDatabase(parsed, options);
|
|
3538
|
+
try {
|
|
3539
|
+
const timestamp = nowIsoSeconds(options);
|
|
3540
|
+
const dispatch = appWakeupDispatchPlanSync(database, {
|
|
3541
|
+
dbPath: runtimeDbPath(parsed, options),
|
|
3542
|
+
dispatcherId: parsed.flags.dispatcherId ?? "dispatch-local",
|
|
3543
|
+
heartbeatStaleSeconds: parsed.flags.appStaleAfterSeconds,
|
|
3544
|
+
now: timestamp,
|
|
3545
|
+
taskName,
|
|
3546
|
+
});
|
|
3547
|
+
const eventId = emitTelemetrySync(database, {
|
|
3548
|
+
actor: "manager",
|
|
3549
|
+
attributes: {
|
|
3550
|
+
actions: dispatch.actions.map((action) => ({
|
|
3551
|
+
blocker: action.blocker,
|
|
3552
|
+
reason: action.reason,
|
|
3553
|
+
role: action.role,
|
|
3554
|
+
send_ready: action.send_ready,
|
|
3555
|
+
status: action.status,
|
|
3556
|
+
thread_id: action.thread.id,
|
|
3557
|
+
thread_title: action.thread.title,
|
|
3558
|
+
})),
|
|
3559
|
+
dispatcher: dispatch.dispatcher,
|
|
3560
|
+
status_ok: dispatch.status.ok,
|
|
3561
|
+
summary: dispatch.summary,
|
|
3562
|
+
},
|
|
3563
|
+
correlation: {
|
|
3564
|
+
command: "app-wakeup-dispatch",
|
|
3565
|
+
dispatcher_id: dispatch.status.dispatch.dispatcher_id,
|
|
3566
|
+
},
|
|
3567
|
+
eventType: "app_wakeup_dispatch_planned",
|
|
3568
|
+
severity: dispatch.summary.blocked > 0 || dispatch.dispatcher.required ? "warning" : "info",
|
|
3569
|
+
summary: `App wakeup dispatch planned for ${dispatch.status.task.name}.`,
|
|
3570
|
+
taskId: dispatch.status.task.id,
|
|
3571
|
+
timestamp,
|
|
3572
|
+
});
|
|
3573
|
+
const output = {
|
|
3574
|
+
...dispatch,
|
|
3575
|
+
receipt: {
|
|
3576
|
+
event_id: eventId,
|
|
3577
|
+
event_type: "app_wakeup_dispatch_planned",
|
|
3578
|
+
recorded_at: timestamp,
|
|
3579
|
+
},
|
|
3580
|
+
};
|
|
3581
|
+
if (parsed.flags.json) {
|
|
3582
|
+
return jsonResult(output);
|
|
3583
|
+
}
|
|
3584
|
+
return {
|
|
3585
|
+
exitCode: 0,
|
|
3586
|
+
handled: true,
|
|
3587
|
+
stdout: renderAppWakeupDispatchText(output),
|
|
3588
|
+
};
|
|
3589
|
+
}
|
|
3590
|
+
finally {
|
|
3591
|
+
database.close();
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
function runAppWakeupRecordDeliveryCommand(parsed, options) {
|
|
3595
|
+
const taskName = requireTask(parsed);
|
|
3596
|
+
if (parsed.flags.role !== "manager" && parsed.flags.role !== "worker") {
|
|
3597
|
+
return errorResult("app-wakeup-record-delivery requires --role manager|worker");
|
|
3598
|
+
}
|
|
3599
|
+
const role = parsed.flags.role;
|
|
3600
|
+
if (!parsed.flags.dispatchReceipt) {
|
|
3601
|
+
return errorResult("app-wakeup-record-delivery requires --dispatch-receipt");
|
|
3602
|
+
}
|
|
3603
|
+
const deliveryStatus = parseAppWakeupDeliveryStatus(parsed.flags.deliveryStatus);
|
|
3604
|
+
if (deliveryStatus instanceof Error) {
|
|
3605
|
+
return errorResult(deliveryStatus.message);
|
|
3606
|
+
}
|
|
3607
|
+
const database = openRuntimeDatabase(parsed, options);
|
|
3608
|
+
try {
|
|
3609
|
+
const task = taskRowForDiagnostics(database, taskName);
|
|
3610
|
+
const source = database.prepare(`
|
|
3611
|
+
select id, task_id, event_type, attributes_json
|
|
3612
|
+
from telemetry_events
|
|
3613
|
+
where id = ?
|
|
3614
|
+
limit 1
|
|
3615
|
+
`).get(parsed.flags.dispatchReceipt);
|
|
3616
|
+
if (!source) {
|
|
3617
|
+
throw new Error(`Unknown app wakeup dispatch receipt: ${parsed.flags.dispatchReceipt}`);
|
|
3618
|
+
}
|
|
3619
|
+
if (source.event_type !== "app_wakeup_dispatch_planned") {
|
|
3620
|
+
throw new Error(`Receipt ${source.id} is ${source.event_type}, expected app_wakeup_dispatch_planned.`);
|
|
3621
|
+
}
|
|
3622
|
+
if (source.task_id !== task.id) {
|
|
3623
|
+
throw new Error(`Receipt ${source.id} does not belong to task ${task.name}.`);
|
|
3624
|
+
}
|
|
3625
|
+
const attributes = parseAppWakeupDispatchAttributes(source.attributes_json, source.id);
|
|
3626
|
+
const action = attributes.actions.find((candidate) => candidate.role === role);
|
|
3627
|
+
if (!action) {
|
|
3628
|
+
throw new Error(`Receipt ${source.id} has no ${role} wake action.`);
|
|
3629
|
+
}
|
|
3630
|
+
validateAppWakeupDelivery({ action, deliveryStatus, role, threadId: parsed.flags.threadId });
|
|
3631
|
+
const timestamp = nowIsoSeconds(options);
|
|
3632
|
+
const eventId = emitTelemetrySync(database, {
|
|
3633
|
+
actor: "manager",
|
|
3634
|
+
attributes: {
|
|
3635
|
+
delivery_status: deliveryStatus,
|
|
3636
|
+
dispatch_receipt: source.id,
|
|
3637
|
+
dispatch_required: attributes.summary.dispatcher_required,
|
|
3638
|
+
reason: parsed.flags.reason,
|
|
3639
|
+
role,
|
|
3640
|
+
source_action_status: action.status,
|
|
3641
|
+
source_send_ready: action.send_ready === true,
|
|
3642
|
+
thread_id: parsed.flags.threadId ?? action.thread_id ?? null,
|
|
3643
|
+
thread_title: action.thread_title ?? null,
|
|
3644
|
+
},
|
|
3645
|
+
correlation: {
|
|
3646
|
+
command: "app-wakeup-record-delivery",
|
|
3647
|
+
dispatch_receipt: source.id,
|
|
3648
|
+
role,
|
|
3649
|
+
thread_id: parsed.flags.threadId ?? action.thread_id ?? null,
|
|
3650
|
+
},
|
|
3651
|
+
eventType: "app_wakeup_delivery_recorded",
|
|
3652
|
+
severity: deliveryStatus === "sent" ? "info" : "warning",
|
|
3653
|
+
summary: `App wakeup delivery ${deliveryStatus} for ${role} on ${task.name}.`,
|
|
3654
|
+
taskId: task.id,
|
|
3655
|
+
timestamp,
|
|
3656
|
+
});
|
|
3657
|
+
const output = {
|
|
3658
|
+
delivery: {
|
|
3659
|
+
reason: parsed.flags.reason,
|
|
3660
|
+
role,
|
|
3661
|
+
source_action_status: action.status,
|
|
3662
|
+
source_send_ready: action.send_ready === true,
|
|
3663
|
+
status: deliveryStatus,
|
|
3664
|
+
thread_id: parsed.flags.threadId ?? action.thread_id ?? null,
|
|
3665
|
+
},
|
|
3666
|
+
receipt: {
|
|
3667
|
+
event_id: eventId,
|
|
3668
|
+
event_type: "app_wakeup_delivery_recorded",
|
|
3669
|
+
recorded_at: timestamp,
|
|
3670
|
+
},
|
|
3671
|
+
source: {
|
|
3672
|
+
dispatch_receipt: source.id,
|
|
3673
|
+
dispatch_required: attributes.summary.dispatcher_required,
|
|
3674
|
+
dispatcher: attributes.dispatcher,
|
|
3675
|
+
},
|
|
3676
|
+
task: { id: task.id, name: task.name },
|
|
3677
|
+
};
|
|
3678
|
+
if (parsed.flags.json) {
|
|
3679
|
+
return jsonResult(output);
|
|
3680
|
+
}
|
|
3681
|
+
return {
|
|
3682
|
+
exitCode: 0,
|
|
3683
|
+
handled: true,
|
|
3684
|
+
stdout: [
|
|
3685
|
+
`App wakeup delivery ${deliveryStatus} recorded for ${role} on ${task.name}.`,
|
|
3686
|
+
`Source receipt: ${source.id}`,
|
|
3687
|
+
`Receipt: ${eventId}`,
|
|
3688
|
+
attributes.summary.dispatcher_required ? "Dispatch still required by source receipt." : "Dispatch healthy in source receipt.",
|
|
3689
|
+
].join("\n") + "\n",
|
|
3690
|
+
};
|
|
3691
|
+
}
|
|
3692
|
+
finally {
|
|
3693
|
+
database.close();
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
function parseAppWakeupDeliveryStatus(value) {
|
|
3697
|
+
if (value === "sent" || value === "skipped" || value === "blocked") {
|
|
3698
|
+
return value;
|
|
3699
|
+
}
|
|
3700
|
+
return new Error("app-wakeup-record-delivery requires --delivery-status sent|skipped|blocked");
|
|
3701
|
+
}
|
|
3702
|
+
function parseAppWakeupDispatchAttributes(value, receiptId) {
|
|
3703
|
+
let parsed;
|
|
3704
|
+
try {
|
|
3705
|
+
parsed = JSON.parse(value);
|
|
3706
|
+
}
|
|
3707
|
+
catch {
|
|
3708
|
+
throw new Error(`Receipt ${receiptId} has invalid attributes JSON.`);
|
|
3709
|
+
}
|
|
3710
|
+
if (!parsed || typeof parsed !== "object") {
|
|
3711
|
+
throw new Error(`Receipt ${receiptId} has invalid attributes shape.`);
|
|
3712
|
+
}
|
|
3713
|
+
const attributes = parsed;
|
|
3714
|
+
if (!Array.isArray(attributes.actions)) {
|
|
3715
|
+
throw new Error(`Receipt ${receiptId} is missing actions.`);
|
|
3716
|
+
}
|
|
3717
|
+
const summary = attributes.summary;
|
|
3718
|
+
if (!summary || typeof summary !== "object" || typeof summary.dispatcher_required !== "boolean") {
|
|
3719
|
+
throw new Error(`Receipt ${receiptId} is missing dispatcher_required summary.`);
|
|
3720
|
+
}
|
|
3721
|
+
const dispatcher = attributes.dispatcher && typeof attributes.dispatcher === "object"
|
|
3722
|
+
? attributes.dispatcher
|
|
3723
|
+
: {};
|
|
3724
|
+
return {
|
|
3725
|
+
actions: attributes.actions,
|
|
3726
|
+
dispatcher,
|
|
3727
|
+
summary: { dispatcher_required: Boolean(summary.dispatcher_required) },
|
|
3728
|
+
};
|
|
3729
|
+
}
|
|
3730
|
+
function validateAppWakeupDelivery(options) {
|
|
3731
|
+
const sourceStatus = parseAppWakeupSourceActionStatus(options.action.status);
|
|
3732
|
+
if (sourceStatus instanceof Error) {
|
|
3733
|
+
throw sourceStatus;
|
|
3734
|
+
}
|
|
3735
|
+
if (options.deliveryStatus === "sent") {
|
|
3736
|
+
if (sourceStatus !== "ready_to_send" || options.action.send_ready !== true) {
|
|
3737
|
+
throw new Error(`Cannot record sent wakeup for ${options.role}; source action is ${sourceStatus}.`);
|
|
3738
|
+
}
|
|
3739
|
+
if (!options.threadId) {
|
|
3740
|
+
throw new Error("app-wakeup-record-delivery --delivery-status sent requires --thread-id.");
|
|
3741
|
+
}
|
|
3742
|
+
if (options.action.thread_id !== options.threadId) {
|
|
3743
|
+
throw new Error(`Thread id mismatch for ${options.role}; source action targets ${options.action.thread_id ?? "(none)"}.`);
|
|
3744
|
+
}
|
|
3745
|
+
return;
|
|
3746
|
+
}
|
|
3747
|
+
if (options.deliveryStatus === "skipped") {
|
|
3748
|
+
if (sourceStatus !== "skipped_healthy") {
|
|
3749
|
+
throw new Error(`Cannot record skipped wakeup for ${options.role}; source action is ${sourceStatus}.`);
|
|
3750
|
+
}
|
|
3751
|
+
return;
|
|
3752
|
+
}
|
|
3753
|
+
if (sourceStatus !== "blocked_missing_thread") {
|
|
3754
|
+
throw new Error(`Cannot record blocked wakeup for ${options.role}; source action is ${sourceStatus}.`);
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
function parseAppWakeupSourceActionStatus(value) {
|
|
3758
|
+
if (value === "ready_to_send" || value === "skipped_healthy" || value === "blocked_missing_thread") {
|
|
3759
|
+
return value;
|
|
3760
|
+
}
|
|
3761
|
+
return new Error(`Unsupported app wakeup source action status: ${value ?? "(missing)"}.`);
|
|
3762
|
+
}
|
|
3763
|
+
function renderAppLoopStatusText(status) {
|
|
3764
|
+
const lines = [
|
|
3765
|
+
`App loop ${status.task.name}: ${status.ok ? "ok" : "attention required"}`,
|
|
3766
|
+
`Dispatch ${status.dispatch.dispatcher_id}: ${status.dispatch.state}`,
|
|
3767
|
+
`Manager ${status.manager.name ?? "(missing)"}: ${status.manager.lease.state}`,
|
|
3768
|
+
`Worker ${status.worker.name ?? "(missing)"}: ${status.worker.lease.state}`,
|
|
3769
|
+
];
|
|
3770
|
+
for (const action of status.next_actions) {
|
|
3771
|
+
lines.push(`Next: ${action.kind} - ${action.reason}`);
|
|
3772
|
+
}
|
|
3773
|
+
return `${lines.join("\n")}\n`;
|
|
3774
|
+
}
|
|
3775
|
+
function renderAppWakeupDispatchText(plan) {
|
|
3776
|
+
const lines = [
|
|
3777
|
+
`App wakeup dispatch for ${plan.status.task.name}: ${plan.status.ok ? "ok" : "attention required"}`,
|
|
3778
|
+
`Dispatch: ${plan.dispatcher.state}${plan.dispatcher.required ? ` (${plan.dispatcher.command})` : ""}`,
|
|
3779
|
+
`Receipt: ${plan.receipt.event_type} ${plan.receipt.event_id}`,
|
|
3780
|
+
];
|
|
3781
|
+
for (const action of plan.actions) {
|
|
3782
|
+
lines.push(`${action.role}: ${action.status} - ${action.reason}`);
|
|
3783
|
+
if (action.blocker) {
|
|
3784
|
+
lines.push(`Blocker: ${action.blocker}`);
|
|
3785
|
+
}
|
|
3786
|
+
if (action.send_ready && action.thread.id) {
|
|
3787
|
+
lines.push(`Thread: ${action.thread.title ?? "(untitled)"} ${action.thread.id}`);
|
|
3788
|
+
}
|
|
3789
|
+
if (action.prompt) {
|
|
3790
|
+
lines.push(action.prompt);
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
return `${lines.join("\n")}\n`;
|
|
3794
|
+
}
|
|
3795
|
+
function renderAppWakeupPlanText(plan) {
|
|
3796
|
+
const lines = [
|
|
3797
|
+
`App wakeup plan for ${plan.status.task.name}: ${plan.status.ok ? "ok" : "attention required"}`,
|
|
3798
|
+
`Dispatch: ${plan.dispatcher.state}${plan.dispatcher.required ? ` (${plan.dispatcher.command})` : ""}`,
|
|
3799
|
+
];
|
|
3800
|
+
for (const wakeup of plan.wakeups) {
|
|
3801
|
+
lines.push(`Wake ${wakeup.role}${wakeup.thread.title ? ` (${wakeup.thread.title})` : ""}: ${wakeup.reason}`);
|
|
3802
|
+
lines.push(wakeup.prompt);
|
|
3803
|
+
}
|
|
3804
|
+
return `${lines.join("\n")}\n`;
|
|
3805
|
+
}
|
|
3806
|
+
function boundAppSessionForRoleSync(database, options) {
|
|
3807
|
+
const sessionJoin = options.role === "manager" ? "manager_session_id" : "worker_session_id";
|
|
3808
|
+
const row = database.prepare(`
|
|
3809
|
+
select s.id, s.name
|
|
3810
|
+
from bindings b
|
|
3811
|
+
join sessions s on s.id = b.${sessionJoin}
|
|
3812
|
+
where b.task_id = ? and b.state in ('active', 'ending') and s.role = ? and s.state = 'active'
|
|
3813
|
+
order by b.created_at desc
|
|
3814
|
+
limit 1
|
|
3815
|
+
`).get(options.taskId, options.role);
|
|
3816
|
+
if (!row) {
|
|
3817
|
+
throw new Error(`No active bound ${options.role} session for task.`);
|
|
3818
|
+
}
|
|
3819
|
+
return row;
|
|
3820
|
+
}
|
|
3350
3821
|
function runQaPlanCommand(parsed) {
|
|
3351
3822
|
const unsupported = unsupportedLoopCommandOptions(parsed, {
|
|
3352
3823
|
allowedFlags: new Set(["json", "subtype"]),
|
|
@@ -13623,6 +14094,11 @@ function isDefaultRuntimeCommand(command) {
|
|
|
13623
14094
|
|| command === "runs"
|
|
13624
14095
|
|| command === "loop-evidence"
|
|
13625
14096
|
|| command === "loop-status"
|
|
14097
|
+
|| command === "app-heartbeat"
|
|
14098
|
+
|| command === "app-loop-status"
|
|
14099
|
+
|| command === "app-wakeup-plan"
|
|
14100
|
+
|| command === "app-wakeup-dispatch"
|
|
14101
|
+
|| command === "app-wakeup-record-delivery"
|
|
13626
14102
|
|| command === "loop-templates"
|
|
13627
14103
|
|| command === "loop-triggers"
|
|
13628
14104
|
|| command === "ralph-loop-presets"
|
|
@@ -16636,6 +17112,8 @@ function renderDisposableBindingText(result) {
|
|
|
16636
17112
|
lines.push(` interval: every ${result.heartbeat_recommendations.interval_minutes} minutes`);
|
|
16637
17113
|
lines.push(` manager: ${result.heartbeat_recommendations.manager.poll_command}`);
|
|
16638
17114
|
lines.push(` worker: ${result.heartbeat_recommendations.worker.poll_command}`);
|
|
17115
|
+
lines.push(` status: ${result.heartbeat_recommendations.status_command}`);
|
|
17116
|
+
lines.push(` wakeup plan: ${result.heartbeat_recommendations.wakeup_plan_command}`);
|
|
16639
17117
|
lines.push(` teardown: ${result.heartbeat_recommendations.teardown_policy.idle_poll}`);
|
|
16640
17118
|
lines.push(` closeout: ${result.heartbeat_recommendations.teardown_policy.terminal_closeout_command}`);
|
|
16641
17119
|
}
|
|
@@ -16645,6 +17123,10 @@ function renderDisposableBindingText(result) {
|
|
|
16645
17123
|
}
|
|
16646
17124
|
function disposableHeartbeatRecommendations(taskName, dbPath) {
|
|
16647
17125
|
const terminalCloseoutCommand = `${conveyorPollInvocation()} finish-task ${shellQuote(taskName)} --reason ${shellQuote("Verified terminal closeout")} --require-criteria-audit --path ${shellQuote(dbPath)}`;
|
|
17126
|
+
const managerHeartbeatCommand = disposableAppHeartbeatCommand("manager", taskName, dbPath);
|
|
17127
|
+
const workerHeartbeatCommand = disposableAppHeartbeatCommand("worker", taskName, dbPath);
|
|
17128
|
+
const managerInboxCommand = sessionPollCommand("manager", taskName, dbPath);
|
|
17129
|
+
const workerInboxCommand = sessionPollCommand("worker", taskName, dbPath);
|
|
16648
17130
|
return {
|
|
16649
17131
|
applies_when: {
|
|
16650
17132
|
can_receive_push: false,
|
|
@@ -16654,6 +17136,7 @@ function disposableHeartbeatRecommendations(taskName, dbPath) {
|
|
|
16654
17136
|
},
|
|
16655
17137
|
interval_minutes: 2,
|
|
16656
17138
|
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
|
+
status_command: `${conveyorPollInvocation()} app-loop-status ${shellQuote(taskName)} --path ${shellQuote(dbPath)} --json`,
|
|
16657
17140
|
teardown_policy: {
|
|
16658
17141
|
idle_poll: "Never delete, pause, or disable a manager or worker heartbeat because an inbox poll returned no item; that is only a quiet poll interval.",
|
|
16659
17142
|
owner: "manager_or_operator",
|
|
@@ -16661,13 +17144,16 @@ function disposableHeartbeatRecommendations(taskName, dbPath) {
|
|
|
16661
17144
|
terminal_closeout_command: terminalCloseoutCommand,
|
|
16662
17145
|
worker_rule: "The worker must not own loop teardown and must not remove heartbeat automation based on idle polling.",
|
|
16663
17146
|
},
|
|
17147
|
+
wakeup_plan_command: `${conveyorPollInvocation()} app-wakeup-plan ${shellQuote(taskName)} --path ${shellQuote(dbPath)} --json`,
|
|
16664
17148
|
manager: {
|
|
17149
|
+
direct_inbox_command: managerInboxCommand,
|
|
16665
17150
|
kind: "thread_heartbeat",
|
|
16666
|
-
poll_command:
|
|
17151
|
+
poll_command: managerHeartbeatCommand,
|
|
16667
17152
|
prompt: [
|
|
16668
17153
|
"Use the manage-codex-workers skill.",
|
|
16669
|
-
`
|
|
16670
|
-
`Run: ${
|
|
17154
|
+
`Run the manager app heartbeat for task ${taskName}.`,
|
|
17155
|
+
`Run: ${managerHeartbeatCommand}`,
|
|
17156
|
+
`If the heartbeat output asks for direct inbox polling, run: ${managerInboxCommand}`,
|
|
16671
17157
|
"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.",
|
|
16672
17158
|
"If no item is consumed, stop after a one-line idle receipt.",
|
|
16673
17159
|
"Do not delete, pause, or disable manager or worker heartbeat automation after an idle poll; an idle poll is only a quiet interval.",
|
|
@@ -16676,12 +17162,14 @@ function disposableHeartbeatRecommendations(taskName, dbPath) {
|
|
|
16676
17162
|
].join("\n"),
|
|
16677
17163
|
},
|
|
16678
17164
|
worker: {
|
|
17165
|
+
direct_inbox_command: workerInboxCommand,
|
|
16679
17166
|
kind: "thread_heartbeat",
|
|
16680
|
-
poll_command:
|
|
17167
|
+
poll_command: workerHeartbeatCommand,
|
|
16681
17168
|
prompt: [
|
|
16682
17169
|
"Use the manage-codex-workers skill.",
|
|
16683
|
-
`
|
|
16684
|
-
`Run: ${
|
|
17170
|
+
`Run the worker app heartbeat for task ${taskName}.`,
|
|
17171
|
+
`Run: ${workerHeartbeatCommand}`,
|
|
17172
|
+
`If the heartbeat output asks for direct inbox polling, run: ${workerInboxCommand}`,
|
|
16685
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.",
|
|
16686
17174
|
"If no item is consumed, stop after a one-line idle receipt.",
|
|
16687
17175
|
"Do not delete, pause, or disable worker heartbeat automation after an idle poll; the manager or operator owns terminal loop teardown.",
|
|
@@ -16689,6 +17177,9 @@ function disposableHeartbeatRecommendations(taskName, dbPath) {
|
|
|
16689
17177
|
},
|
|
16690
17178
|
};
|
|
16691
17179
|
}
|
|
17180
|
+
function disposableAppHeartbeatCommand(role, taskName, dbPath) {
|
|
17181
|
+
return `${conveyorPollInvocation()} app-heartbeat ${shellQuote(taskName)} --role ${role} --path ${shellQuote(dbPath)} --json`;
|
|
17182
|
+
}
|
|
16692
17183
|
function sessionPollCommand(role, taskName, dbPath) {
|
|
16693
17184
|
const inbox = role === "worker" ? "worker-inbox" : "manager-inbox";
|
|
16694
17185
|
const task = taskName ? shellQuote(taskName) : "<task>";
|
|
@@ -16944,6 +17435,7 @@ function emitTelemetrySync(database, options) {
|
|
|
16944
17435
|
)
|
|
16945
17436
|
values (?, ?, ?, ?, ?, ?, ?)
|
|
16946
17437
|
`).run(eventId, options.taskId ?? null, options.runId ?? null, options.actor, options.eventType, options.summary, attributesJson);
|
|
17438
|
+
return eventId;
|
|
16947
17439
|
}
|
|
16948
17440
|
function latestCodexEventsForSession(database, options) {
|
|
16949
17441
|
const clauses = ["session_id = ?"];
|