botmux 2.93.1 → 2.95.0
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/dist/adapters/backend/tmux-backend.d.ts +18 -2
- package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-backend.js +45 -3
- package/dist/adapters/backend/tmux-backend.js.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
- package/dist/adapters/backend/types.d.ts +8 -0
- package/dist/adapters/backend/types.d.ts.map +1 -1
- package/dist/adapters/backend/types.js.map +1 -1
- package/dist/adapters/backend/zellij-backend.js +1 -1
- package/dist/adapters/backend/zellij-backend.js.map +1 -1
- package/dist/adapters/cli/claude-code.d.ts.map +1 -1
- package/dist/adapters/cli/claude-code.js +6 -1
- package/dist/adapters/cli/claude-code.js.map +1 -1
- package/dist/bot-registry.d.ts +50 -1
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +30 -0
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli/workflow.d.ts.map +1 -1
- package/dist/cli/workflow.js +11 -4
- package/dist/cli/workflow.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +146 -1
- package/dist/cli.js.map +1 -1
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +28 -3
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +92 -0
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/inherit-peer.d.ts +5 -0
- package/dist/core/inherit-peer.d.ts.map +1 -1
- package/dist/core/inherit-peer.js +3 -0
- package/dist/core/inherit-peer.js.map +1 -1
- package/dist/core/session-discovery.d.ts +9 -0
- package/dist/core/session-discovery.d.ts.map +1 -1
- package/dist/core/session-discovery.js +24 -0
- package/dist/core/session-discovery.js.map +1 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +7 -6
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/terminal-url.d.ts.map +1 -1
- package/dist/core/terminal-url.js +15 -0
- package/dist/core/terminal-url.js.map +1 -1
- package/dist/core/trigger-session.js +2 -2
- package/dist/core/trigger-session.js.map +1 -1
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +30 -0
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +296 -23
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/aggregator.d.ts +5 -0
- package/dist/dashboard/aggregator.d.ts.map +1 -1
- package/dist/dashboard/aggregator.js +7 -0
- package/dist/dashboard/aggregator.js.map +1 -1
- package/dist/dashboard/auth.d.ts.map +1 -1
- package/dist/dashboard/auth.js +7 -1
- package/dist/dashboard/auth.js.map +1 -1
- package/dist/dashboard/bot-payload.d.ts +2 -0
- package/dist/dashboard/bot-payload.d.ts.map +1 -1
- package/dist/dashboard/bot-payload.js +2 -0
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/connector-api.d.ts.map +1 -1
- package/dist/dashboard/connector-api.js +14 -3
- package/dist/dashboard/connector-api.js.map +1 -1
- package/dist/dashboard/settings-write-applier.d.ts +2 -1
- package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
- package/dist/dashboard/settings-write-applier.js +7 -0
- package/dist/dashboard/settings-write-applier.js.map +1 -1
- package/dist/dashboard/v3-runs-api.d.ts +26 -0
- package/dist/dashboard/v3-runs-api.d.ts.map +1 -0
- package/dist/dashboard/v3-runs-api.js +67 -0
- package/dist/dashboard/v3-runs-api.js.map +1 -0
- package/dist/dashboard/web/app.js +21 -9
- package/dist/dashboard/web/app.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults.js +128 -33
- package/dist/dashboard/web/bot-defaults.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +42 -6
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/legacy-workflow-link.d.ts +2 -0
- package/dist/dashboard/web/legacy-workflow-link.d.ts.map +1 -0
- package/dist/dashboard/web/legacy-workflow-link.js +12 -0
- package/dist/dashboard/web/legacy-workflow-link.js.map +1 -0
- package/dist/dashboard/web/settings.d.ts.map +1 -1
- package/dist/dashboard/web/settings.js +11 -0
- package/dist/dashboard/web/settings.js.map +1 -1
- package/dist/dashboard/web/v3-terminal.d.ts +11 -0
- package/dist/dashboard/web/v3-terminal.d.ts.map +1 -0
- package/dist/dashboard/web/v3-terminal.js +143 -0
- package/dist/dashboard/web/v3-terminal.js.map +1 -0
- package/dist/dashboard/web/v3.d.ts +2 -0
- package/dist/dashboard/web/v3.d.ts.map +1 -0
- package/dist/dashboard/web/v3.js +564 -0
- package/dist/dashboard/web/v3.js.map +1 -0
- package/dist/dashboard/web/whiteboards.d.ts.map +1 -1
- package/dist/dashboard/web/whiteboards.js +14 -5
- package/dist/dashboard/web/whiteboards.js.map +1 -1
- package/dist/dashboard/web/workflows.d.ts.map +1 -1
- package/dist/dashboard/web/workflows.js +12 -8
- package/dist/dashboard/web/workflows.js.map +1 -1
- package/dist/dashboard-web/app.js +834 -724
- package/dist/dashboard-web/apple-touch-icon.png +0 -0
- package/dist/dashboard-web/favicon.png +0 -0
- package/dist/dashboard-web/index.html +3 -1
- package/dist/dashboard-web/style.css +223 -0
- package/dist/dashboard.js +170 -10
- package/dist/dashboard.js.map +1 -1
- package/dist/global-config.d.ts +10 -0
- package/dist/global-config.d.ts.map +1 -1
- package/dist/global-config.js +8 -0
- package/dist/global-config.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +23 -3
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +23 -3
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts +12 -0
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +28 -0
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +3 -12
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +2 -1
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +20 -6
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/im/lark/grant-command.js +4 -4
- package/dist/im/lark/grant-command.js.map +1 -1
- package/dist/im/lark/message-parser.d.ts +29 -1
- package/dist/im/lark/message-parser.d.ts.map +1 -1
- package/dist/im/lark/message-parser.js +35 -1
- package/dist/im/lark/message-parser.js.map +1 -1
- package/dist/im/lark/v3-blocked-card-handler.d.ts +28 -0
- package/dist/im/lark/v3-blocked-card-handler.d.ts.map +1 -0
- package/dist/im/lark/v3-blocked-card-handler.js +134 -0
- package/dist/im/lark/v3-blocked-card-handler.js.map +1 -0
- package/dist/im/lark/v3-blocked-card.d.ts +75 -0
- package/dist/im/lark/v3-blocked-card.d.ts.map +1 -0
- package/dist/im/lark/v3-blocked-card.js +206 -0
- package/dist/im/lark/v3-blocked-card.js.map +1 -0
- package/dist/im/lark/v3-gate-card-handler.d.ts +31 -0
- package/dist/im/lark/v3-gate-card-handler.d.ts.map +1 -0
- package/dist/im/lark/v3-gate-card-handler.js +110 -0
- package/dist/im/lark/v3-gate-card-handler.js.map +1 -0
- package/dist/im/lark/v3-gate-card.d.ts +47 -0
- package/dist/im/lark/v3-gate-card.d.ts.map +1 -0
- package/dist/im/lark/v3-gate-card.js +119 -0
- package/dist/im/lark/v3-gate-card.js.map +1 -0
- package/dist/im/lark/v3-loop-grant-card-handler.d.ts +28 -0
- package/dist/im/lark/v3-loop-grant-card-handler.d.ts.map +1 -0
- package/dist/im/lark/v3-loop-grant-card-handler.js +90 -0
- package/dist/im/lark/v3-loop-grant-card-handler.js.map +1 -0
- package/dist/im/lark/v3-loop-grant-card.d.ts +46 -0
- package/dist/im/lark/v3-loop-grant-card.d.ts.map +1 -0
- package/dist/im/lark/v3-loop-grant-card.js +119 -0
- package/dist/im/lark/v3-loop-grant-card.js.map +1 -0
- package/dist/im/lark/v3-revisit-grant-card-handler.d.ts +29 -0
- package/dist/im/lark/v3-revisit-grant-card-handler.d.ts.map +1 -0
- package/dist/im/lark/v3-revisit-grant-card-handler.js +96 -0
- package/dist/im/lark/v3-revisit-grant-card-handler.js.map +1 -0
- package/dist/im/lark/v3-revisit-grant-card.d.ts +52 -0
- package/dist/im/lark/v3-revisit-grant-card.d.ts.map +1 -0
- package/dist/im/lark/v3-revisit-grant-card.js +123 -0
- package/dist/im/lark/v3-revisit-grant-card.js.map +1 -0
- package/dist/im/lark/workflow-cards.d.ts.map +1 -1
- package/dist/im/lark/workflow-cards.js +5 -1
- package/dist/im/lark/workflow-cards.js.map +1 -1
- package/dist/im/lark/workflow-progress-card.d.ts +1 -1
- package/dist/im/lark/workflow-progress-card.js +1 -1
- package/dist/im/lark/workflow-slash-command.d.ts +28 -0
- package/dist/im/lark/workflow-slash-command.d.ts.map +1 -1
- package/dist/im/lark/workflow-slash-command.js +61 -5
- package/dist/im/lark/workflow-slash-command.js.map +1 -1
- package/dist/platform/binding.d.ts +6 -0
- package/dist/platform/binding.d.ts.map +1 -1
- package/dist/platform/binding.js +17 -0
- package/dist/platform/binding.js.map +1 -1
- package/dist/platform/tunnel-client.d.ts +2 -0
- package/dist/platform/tunnel-client.d.ts.map +1 -1
- package/dist/platform/tunnel-client.js.map +1 -1
- package/dist/services/bot-config-store.d.ts.map +1 -1
- package/dist/services/bot-config-store.js +1 -0
- package/dist/services/bot-config-store.js.map +1 -1
- package/dist/services/card-prefs-store.d.ts +4 -0
- package/dist/services/card-prefs-store.d.ts.map +1 -1
- package/dist/services/card-prefs-store.js +19 -0
- package/dist/services/card-prefs-store.js.map +1 -1
- package/dist/services/oncall-store.d.ts +25 -0
- package/dist/services/oncall-store.d.ts.map +1 -1
- package/dist/services/oncall-store.js +56 -0
- package/dist/services/oncall-store.js.map +1 -1
- package/dist/services/transcript-resolver.d.ts.map +1 -1
- package/dist/services/transcript-resolver.js +21 -4
- package/dist/services/transcript-resolver.js.map +1 -1
- package/dist/setup/bot-config-editor.d.ts +7 -0
- package/dist/setup/bot-config-editor.d.ts.map +1 -1
- package/dist/setup/bot-config-editor.js +33 -0
- package/dist/setup/bot-config-editor.js.map +1 -1
- package/dist/skills/definitions.d.ts.map +1 -1
- package/dist/skills/definitions.js +208 -4
- package/dist/skills/definitions.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/terminal-favicon.d.ts +2 -0
- package/dist/utils/terminal-favicon.d.ts.map +1 -0
- package/dist/utils/terminal-favicon.js +5 -0
- package/dist/utils/terminal-favicon.js.map +1 -0
- package/dist/worker.js +152 -8
- package/dist/worker.js.map +1 -1
- package/dist/workflows/definition.d.ts +80 -80
- package/dist/workflows/events/payloads.d.ts +4 -4
- package/dist/workflows/events/schema.d.ts +16 -16
- package/dist/workflows/v3/architect.d.ts +37 -0
- package/dist/workflows/v3/architect.d.ts.map +1 -0
- package/dist/workflows/v3/architect.js +226 -0
- package/dist/workflows/v3/architect.js.map +1 -0
- package/dist/workflows/v3/bot-resolve.d.ts +21 -0
- package/dist/workflows/v3/bot-resolve.d.ts.map +1 -0
- package/dist/workflows/v3/bot-resolve.js +48 -0
- package/dist/workflows/v3/bot-resolve.js.map +1 -0
- package/dist/workflows/v3/cli-run.d.ts +24 -0
- package/dist/workflows/v3/cli-run.d.ts.map +1 -0
- package/dist/workflows/v3/cli-run.js +265 -0
- package/dist/workflows/v3/cli-run.js.map +1 -0
- package/dist/workflows/v3/contract.d.ts +302 -0
- package/dist/workflows/v3/contract.d.ts.map +1 -0
- package/dist/workflows/v3/contract.js +91 -0
- package/dist/workflows/v3/contract.js.map +1 -0
- package/dist/workflows/v3/daemon-run.d.ts +311 -0
- package/dist/workflows/v3/daemon-run.d.ts.map +1 -0
- package/dist/workflows/v3/daemon-run.js +775 -0
- package/dist/workflows/v3/daemon-run.js.map +1 -0
- package/dist/workflows/v3/dag.d.ts +332 -0
- package/dist/workflows/v3/dag.d.ts.map +1 -0
- package/dist/workflows/v3/dag.js +1040 -0
- package/dist/workflows/v3/dag.js.map +1 -0
- package/dist/workflows/v3/ephemeral-pool.d.ts +29 -0
- package/dist/workflows/v3/ephemeral-pool.d.ts.map +1 -0
- package/dist/workflows/v3/ephemeral-pool.js +331 -0
- package/dist/workflows/v3/ephemeral-pool.js.map +1 -0
- package/dist/workflows/v3/grill-state.d.ts +92 -0
- package/dist/workflows/v3/grill-state.d.ts.map +1 -0
- package/dist/workflows/v3/grill-state.js +151 -0
- package/dist/workflows/v3/grill-state.js.map +1 -0
- package/dist/workflows/v3/host.d.ts +88 -0
- package/dist/workflows/v3/host.d.ts.map +1 -0
- package/dist/workflows/v3/host.js +349 -0
- package/dist/workflows/v3/host.js.map +1 -0
- package/dist/workflows/v3/human-gate.d.ts +85 -0
- package/dist/workflows/v3/human-gate.d.ts.map +1 -0
- package/dist/workflows/v3/human-gate.js +153 -0
- package/dist/workflows/v3/human-gate.js.map +1 -0
- package/dist/workflows/v3/journal.d.ts +207 -0
- package/dist/workflows/v3/journal.d.ts.map +1 -0
- package/dist/workflows/v3/journal.js +74 -0
- package/dist/workflows/v3/journal.js.map +1 -0
- package/dist/workflows/v3/manifest.d.ts +17 -0
- package/dist/workflows/v3/manifest.d.ts.map +1 -0
- package/dist/workflows/v3/manifest.js +266 -0
- package/dist/workflows/v3/manifest.js.map +1 -0
- package/dist/workflows/v3/ops-projection.d.ts +110 -0
- package/dist/workflows/v3/ops-projection.d.ts.map +1 -0
- package/dist/workflows/v3/ops-projection.js +0 -0
- package/dist/workflows/v3/ops-projection.js.map +1 -0
- package/dist/workflows/v3/orchestrator.d.ts +168 -0
- package/dist/workflows/v3/orchestrator.d.ts.map +1 -0
- package/dist/workflows/v3/orchestrator.js +417 -0
- package/dist/workflows/v3/orchestrator.js.map +1 -0
- package/dist/workflows/v3/runtime.d.ts +193 -0
- package/dist/workflows/v3/runtime.d.ts.map +1 -0
- package/dist/workflows/v3/runtime.js +1284 -0
- package/dist/workflows/v3/runtime.js.map +1 -0
- package/dist/workflows/v3/spec.d.ts +32 -0
- package/dist/workflows/v3/spec.d.ts.map +1 -0
- package/dist/workflows/v3/spec.js +173 -0
- package/dist/workflows/v3/spec.js.map +1 -0
- package/dist/workflows/v3/state.d.ts +74 -0
- package/dist/workflows/v3/state.d.ts.map +1 -0
- package/dist/workflows/v3/state.js +353 -0
- package/dist/workflows/v3/state.js.map +1 -0
- package/package.json +2 -2
- package/dist/dashboard/web/workflow-catalog.d.ts +0 -2
- package/dist/dashboard/web/workflow-catalog.d.ts.map +0 -1
- package/dist/dashboard/web/workflow-catalog.js +0 -323
- package/dist/dashboard/web/workflow-catalog.js.map +0 -1
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon-driven v3 run — the Feishu-product execution path (vs `cli-run.ts`'s
|
|
3
|
+
* dev/dogfood terminal path). Mirrors v0.2's `driveWorkflowRun`, but for the
|
|
4
|
+
* v3 engine and with **suspend-mode gates**:
|
|
5
|
+
*
|
|
6
|
+
* - `gateMode:'suspend'`: when a humanGate is reached the runtime writes the
|
|
7
|
+
* pending wait file + returns `awaitingGate` WITHOUT awaiting a decision —
|
|
8
|
+
* no in-memory promise to lose on a daemon restart.
|
|
9
|
+
* - This driver posts the approval card(s) for each pending gate and returns.
|
|
10
|
+
* It does NOT hold the run. A card click resolves the wait (+ appends
|
|
11
|
+
* `gateResolved`) and RE-INVOKES `driveV3Run` for a fresh replay that picks
|
|
12
|
+
* up the now-`gateCleared` node and continues.
|
|
13
|
+
*
|
|
14
|
+
* Stateless by design (recovery source = runDir dag/journal/wait/chatBinding).
|
|
15
|
+
* The daemon owns a lightweight per-runId in-flight guard around this so two
|
|
16
|
+
* concurrent clicks / start can't double-spawn.
|
|
17
|
+
*/
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { loadBotConfigs } from '../../bot-registry.js';
|
|
21
|
+
import { isLoopNode, loadDag } from './dag.js';
|
|
22
|
+
import { runWorkflow, nextAttemptIdFor, latestAttemptIdFor, revisitBudgetStatus, } from './runtime.js';
|
|
23
|
+
import { createEphemeralPool } from './ephemeral-pool.js';
|
|
24
|
+
import { readAndValidateManifest, ManifestValidationError } from './manifest.js';
|
|
25
|
+
import { readGrillState, defaultBaseDir, } from './grill-state.js';
|
|
26
|
+
import { resolveBotConfig, botToSnapshot } from './bot-resolve.js';
|
|
27
|
+
import { canResolveGateWait, normalizeGateWaitInput, readWait, resolveWait, selectedResolution, writePendingWait, } from './human-gate.js';
|
|
28
|
+
import { readJournal, appendEvent } from './journal.js';
|
|
29
|
+
import { materialize } from './state.js';
|
|
30
|
+
import { isValidRunId } from './ops-projection.js';
|
|
31
|
+
import { GOAL_ANSWER_FILE } from './contract.js';
|
|
32
|
+
/**
|
|
33
|
+
* runId → runDir with a path-traversal guard (codex review #2). runIds reach
|
|
34
|
+
* the daemon from outside (start IPC, card clicks) — never trust them into a
|
|
35
|
+
* `join` without the allowlist check, so the guard lives in core (not glue).
|
|
36
|
+
*/
|
|
37
|
+
export function safeRunDir(baseDir, runId) {
|
|
38
|
+
if (!isValidRunId(runId))
|
|
39
|
+
throw new Error(`v3: invalid runId "${runId}"`);
|
|
40
|
+
return join(baseDir, runId);
|
|
41
|
+
}
|
|
42
|
+
/** Latest `nodeBlocked` details for a node (card content). Falls back to a
|
|
43
|
+
* bare nodeId/attempt when the journal has no blocked event (shouldn't
|
|
44
|
+
* happen for a blocked run, but the card must still render). */
|
|
45
|
+
export function blockedInfoFor(events, nodeId) {
|
|
46
|
+
let found;
|
|
47
|
+
for (const e of events) {
|
|
48
|
+
if (e.type === 'nodeBlocked' && e.nodeId === nodeId) {
|
|
49
|
+
found = {
|
|
50
|
+
nodeId,
|
|
51
|
+
attemptId: e.attemptId,
|
|
52
|
+
errorClass: e.errorClass,
|
|
53
|
+
errorCode: e.errorCode,
|
|
54
|
+
message: e.message,
|
|
55
|
+
...(e.ask ? { ask: e.ask } : {}),
|
|
56
|
+
...(e.revisitTo ? { revisitTo: e.revisitTo } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return found ?? { nodeId, attemptId: latestAttemptIdFor(events, nodeId) ?? `${nodeId}/attempts/001` };
|
|
61
|
+
}
|
|
62
|
+
export function revisitBudgetBlockedInfoFor(events, nodeId) {
|
|
63
|
+
const info = blockedInfoFor(events, nodeId);
|
|
64
|
+
if (info.errorCode !== 'REVISIT_BUDGET_EXHAUSTED' || !info.revisitTo)
|
|
65
|
+
return undefined;
|
|
66
|
+
const status = revisitBudgetStatus(events, nodeId, info.revisitTo);
|
|
67
|
+
// Exhausted at block time; if a grant already lifted it the card is stale —
|
|
68
|
+
// fall back to 'pair' tier + the recorded message for a still-renderable card.
|
|
69
|
+
return {
|
|
70
|
+
sourceNodeId: nodeId,
|
|
71
|
+
toNodeId: info.revisitTo,
|
|
72
|
+
attemptId: info.attemptId,
|
|
73
|
+
tier: status.ok ? 'pair' : status.tier,
|
|
74
|
+
detail: status.ok ? (info.message ?? 'revisit budget') : status.detail,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** Fold the exhausted-loop card content from the journal. Pure. */
|
|
78
|
+
export function loopExhaustedInfoFor(events, loopId) {
|
|
79
|
+
const ls = materialize(events).loops.get(loopId);
|
|
80
|
+
let detail;
|
|
81
|
+
for (const e of events) {
|
|
82
|
+
if (e.type === 'loopIterationDecision' && e.loopId === loopId)
|
|
83
|
+
detail = e.detail;
|
|
84
|
+
}
|
|
85
|
+
return { loopId, iteration: ls?.iteration ?? 0, granted: ls?.granted ?? 0, detail };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Drive a daemon-side v3 run to its next suspension point (a gate) or terminal.
|
|
89
|
+
* Returns the runtime outcome. Throws on: missing grill state, no approved
|
|
90
|
+
* dag, or awaitingGate with no chatBinding (can't post a card).
|
|
91
|
+
*/
|
|
92
|
+
export async function driveV3Run(runId, deps) {
|
|
93
|
+
const baseDir = deps.baseDir ?? defaultBaseDir();
|
|
94
|
+
const runDir = safeRunDir(baseDir, runId);
|
|
95
|
+
const grill = readGrillState(runDir);
|
|
96
|
+
if (!grill)
|
|
97
|
+
throw new Error(`v3 daemon run: no grill state for "${runId}" in ${runDir}`);
|
|
98
|
+
if (!grill.dagPath || !existsSync(grill.dagPath)) {
|
|
99
|
+
throw new Error(`v3 daemon run: "${runId}" has no approved dag (status=${grill.status})`);
|
|
100
|
+
}
|
|
101
|
+
const binding = grill.chatBinding;
|
|
102
|
+
// Was the run ALREADY terminal before this (re-)drive? A coalesced re-drive or
|
|
103
|
+
// a `/start` retry of a finished run re-runs no work (the journal is terminal),
|
|
104
|
+
// but the notify block below would otherwise re-post cards / re-fire onTerminal
|
|
105
|
+
// — duplicate "done/failed" messages. Capture now, short-circuit after runWorkflow.
|
|
106
|
+
const journalPath = join(runDir, 'journal.ndjson');
|
|
107
|
+
const wasAlreadyTerminal = existsSync(journalPath) &&
|
|
108
|
+
['succeeded', 'failed'].includes(materialize(readJournal(journalPath)).runStatus);
|
|
109
|
+
const bots = (deps.loadBots ?? loadBotConfigs)();
|
|
110
|
+
// Secret resolver by larkAppId from live bots.json; no env fallback (contract).
|
|
111
|
+
const secretById = new Map(bots.map((b) => [b.larkAppId, b.larkAppSecret]));
|
|
112
|
+
const resolveLarkAppSecret = (larkAppId) => secretById.get(larkAppId);
|
|
113
|
+
// codex's throw-based validator → runtime's result-style seam (override-able for tests).
|
|
114
|
+
const validateManifest = deps.validateManifest ?? (async (manifestPath, outputDir) => {
|
|
115
|
+
try {
|
|
116
|
+
const manifest = await readAndValidateManifest(manifestPath, outputDir);
|
|
117
|
+
return { ok: true, manifest };
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
return { ok: false, problems: e instanceof ManifestValidationError ? e.problems : [String(e)] };
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
const resolveBotSnapshot = (botId) => botToSnapshot(resolveBotConfig(botId, bots));
|
|
124
|
+
const runNode = (deps.makeRunNode ?? defaultMakeRunNode)(resolveLarkAppSecret);
|
|
125
|
+
const dag = loadDag(grill.dagPath);
|
|
126
|
+
// suspend mode → no resolveGate (runtime writes the wait + returns awaitingGate).
|
|
127
|
+
const runtimeDeps = { runNode, validateManifest, resolveBotSnapshot };
|
|
128
|
+
const opts = {
|
|
129
|
+
baseDir,
|
|
130
|
+
gateMode: 'suspend',
|
|
131
|
+
...(deps.maxParallel ? { globalConcurrency: deps.maxParallel } : {}),
|
|
132
|
+
};
|
|
133
|
+
const outcome = await runWorkflow(dag, runtimeDeps, opts);
|
|
134
|
+
if (wasAlreadyTerminal) {
|
|
135
|
+
// Re-drive of an already-finished run: no work re-ran, so don't re-post
|
|
136
|
+
// gate/blocked cards or re-fire onTerminal (would duplicate the terminal msg).
|
|
137
|
+
return outcome;
|
|
138
|
+
}
|
|
139
|
+
if (outcome.reason === 'awaitingGate') {
|
|
140
|
+
if (!binding) {
|
|
141
|
+
// No chat binding (e.g. not born via grill) → can't post a card. The
|
|
142
|
+
// wait files are on disk; surface rather than silently strand the run.
|
|
143
|
+
throw new Error(`v3 daemon run "${runId}" is awaiting gate(s) but has no chatBinding — cannot post approval card`);
|
|
144
|
+
}
|
|
145
|
+
for (const gate of outcome.pendingWaits) {
|
|
146
|
+
await deps.postGateCard(binding, gate, runId);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else if (outcome.runStatus === 'blocked' && outcome.blockedNodeId && binding) {
|
|
150
|
+
// Blocked = terminal-for-now. Two distinct causes, two distinct cards:
|
|
151
|
+
// - exhausted LOOP (blockedNodeId is a loop id; nothing "failed", the
|
|
152
|
+
// work just didn't converge) → grant card (+1 iteration);
|
|
153
|
+
// - blocked node/instance (contract failure) → retry card (new attempt).
|
|
154
|
+
const events = readJournal(join(runDir, 'journal.ndjson'));
|
|
155
|
+
const isLoop = materialize(events).loops.has(outcome.blockedNodeId);
|
|
156
|
+
const revisitBudget = revisitBudgetBlockedInfoFor(events, outcome.blockedNodeId);
|
|
157
|
+
if (isLoop && deps.postLoopGrantCard) {
|
|
158
|
+
const info = loopExhaustedInfoFor(events, outcome.blockedNodeId);
|
|
159
|
+
const loopNode = dag.nodes.find((n) => n.id === outcome.blockedNodeId);
|
|
160
|
+
if (loopNode && isLoopNode(loopNode))
|
|
161
|
+
info.maxIterations = loopNode.maxIterations;
|
|
162
|
+
await deps.postLoopGrantCard(binding, info, runId);
|
|
163
|
+
}
|
|
164
|
+
else if (revisitBudget && deps.postRevisitGrantCard) {
|
|
165
|
+
// Revisit budget exhausted → grant card (+1 revisit), not a plain retry.
|
|
166
|
+
await deps.postRevisitGrantCard(binding, revisitBudget, runId);
|
|
167
|
+
}
|
|
168
|
+
else if (!isLoop && deps.postBlockedCard) {
|
|
169
|
+
await deps.postBlockedCard(binding, blockedInfoFor(events, outcome.blockedNodeId), runId);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
await deps.onTerminal?.(runId, outcome, binding);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
await deps.onTerminal?.(runId, outcome, binding);
|
|
177
|
+
}
|
|
178
|
+
return outcome;
|
|
179
|
+
}
|
|
180
|
+
function defaultMakeRunNode(resolveLarkAppSecret) {
|
|
181
|
+
const { runNode } = createEphemeralPool({ resolveLarkAppSecret });
|
|
182
|
+
return runNode;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Resolve a humanGate approval-card click. Idempotent + terminal-safe (codex
|
|
186
|
+
* review #5):
|
|
187
|
+
* 1. run terminal / journal missing → `stale-run` (caller toasts, does NOT
|
|
188
|
+
* redrive — a finished run must not be pulled back to life by a stale card).
|
|
189
|
+
* 2. wait missing / non-pending → `stale-run`(no-wait) / `already-settled`
|
|
190
|
+
* (caller toasts, no redrive — guards repeat clicks).
|
|
191
|
+
* 3. pending → `resolveWait` (atomic — THE idempotency guard) THEN append
|
|
192
|
+
* `gateResolved`. Returns `resolved` → caller redrives.
|
|
193
|
+
*
|
|
194
|
+
* Order is wait-first on purpose: a crash between the two leaves the wait
|
|
195
|
+
* settled (future clicks → already-settled, no double-resolve); the rare
|
|
196
|
+
* wait-resolved-but-journal-missing gap is healed by cold-attach reconcile.
|
|
197
|
+
* If the journal append throws, this throws — the caller must warn and NOT
|
|
198
|
+
* fake UI success (codex #5).
|
|
199
|
+
*/
|
|
200
|
+
export function resolveV3GateClick(baseDir, runId, input) {
|
|
201
|
+
const runDir = safeRunDir(baseDir, runId);
|
|
202
|
+
const journalPath = join(runDir, 'journal.ndjson');
|
|
203
|
+
if (!existsSync(journalPath))
|
|
204
|
+
return { kind: 'stale-run', reason: 'missing' };
|
|
205
|
+
const snap = materialize(readJournal(journalPath));
|
|
206
|
+
if (snap.runStatus !== 'running')
|
|
207
|
+
return { kind: 'stale-run', reason: 'terminal' };
|
|
208
|
+
const wait = readWait(runDir, input.waitId);
|
|
209
|
+
if (!wait)
|
|
210
|
+
return { kind: 'stale-run', reason: 'no-wait' };
|
|
211
|
+
if (wait.status !== 'pending')
|
|
212
|
+
return { kind: 'already-settled', status: wait.status };
|
|
213
|
+
if (snap.nodes.get(wait.nodeId)?.status !== 'gateWaiting') {
|
|
214
|
+
return { kind: 'stale-run', reason: 'stale-node' };
|
|
215
|
+
}
|
|
216
|
+
// Stale-card guard: the wait must belong to the node's CURRENT effective
|
|
217
|
+
// instance. A revisit makes a fresh instance + gate (`A#002-gate`); an old
|
|
218
|
+
// `A#001-gate` card must NOT resolve the new instance's gate.
|
|
219
|
+
if (wait.instanceId && wait.instanceId !== snap.nodes.get(wait.nodeId)?.effectiveInstanceId) {
|
|
220
|
+
return { kind: 'stale-run', reason: 'stale-node' };
|
|
221
|
+
}
|
|
222
|
+
if (!canResolveGateWait(wait, input.by))
|
|
223
|
+
return { kind: 'unauthorized' };
|
|
224
|
+
const resolution = selectedResolution(wait, input.selected);
|
|
225
|
+
if (!resolution)
|
|
226
|
+
return { kind: 'stale-run', reason: 'no-wait' };
|
|
227
|
+
resolveWait(runDir, input.waitId, resolution, input.by, input.selected);
|
|
228
|
+
const instanceId = snap.nodes.get(wait.nodeId)?.effectiveInstanceId;
|
|
229
|
+
appendEvent(journalPath, {
|
|
230
|
+
type: 'gateResolved',
|
|
231
|
+
// nodeId from the WAIT FILE, not caller input (codex review #1): the wait is
|
|
232
|
+
// the authoritative state — a wrong/stale caller nodeId must not let us write
|
|
233
|
+
// gateResolved for a different node.
|
|
234
|
+
nodeId: wait.nodeId,
|
|
235
|
+
...(instanceId ? { instanceId } : {}),
|
|
236
|
+
waitId: input.waitId,
|
|
237
|
+
resolution,
|
|
238
|
+
by: input.by,
|
|
239
|
+
selected: input.selected,
|
|
240
|
+
});
|
|
241
|
+
return { kind: 'resolved', resolution };
|
|
242
|
+
}
|
|
243
|
+
function atomicWriteJson(path, value) {
|
|
244
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
245
|
+
const tmp = `${path}.tmp`;
|
|
246
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2));
|
|
247
|
+
renameSync(tmp, path);
|
|
248
|
+
}
|
|
249
|
+
const HUMAN_ANSWER_PREVIEW_MAX_CHARS = 200;
|
|
250
|
+
function answerPreview(s) {
|
|
251
|
+
return s.length <= HUMAN_ANSWER_PREVIEW_MAX_CHARS
|
|
252
|
+
? s
|
|
253
|
+
: `${s.slice(0, HUMAN_ANSWER_PREVIEW_MAX_CHARS)}...`;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Append a retry intent for a blocked node (the resume entrypoint — daemon
|
|
257
|
+
* card click and `botmux workflow retry` both land here). Recovery-first +
|
|
258
|
+
* idempotent (codex v2 of the blocked design):
|
|
259
|
+
* 1. fresh `materialize(readJournal)` — the journal is the recovery source;
|
|
260
|
+
* a node that already succeeded / re-dispatched is seen as such.
|
|
261
|
+
* 2. the target node must STILL be materialized `blocked`. A node already
|
|
262
|
+
* reset to pending by an unconsumed `nodeRetryRequested` → already-requested
|
|
263
|
+
* (no second append); anything else → stale.
|
|
264
|
+
* 3. `expectedAttemptId` (card clicks pass the card's attempt): the retry is
|
|
265
|
+
* only valid for the attempt that is CURRENTLY blocked — a stale card from
|
|
266
|
+
* attempt 001 must not advance attempt 002's blocked to 003 (codex
|
|
267
|
+
* blocker, slice-1 review). The card nonce alone only proves the card's
|
|
268
|
+
* own integrity, not freshness. CLI omits it ("retry whatever is blocked").
|
|
269
|
+
* 4. append `nodeRetryRequested` with the reserved nextAttemptId and the
|
|
270
|
+
* previous blocked event's errorClass/errorCode copied in for audit.
|
|
271
|
+
* The caller re-drives (materialize folds the retry into pending → orchestrator
|
|
272
|
+
* re-dispatches with the reserved attempt number).
|
|
273
|
+
*/
|
|
274
|
+
export function requestV3Retry(baseDir, runId, input = {}) {
|
|
275
|
+
const runDir = safeRunDir(baseDir, runId);
|
|
276
|
+
const journalPath = join(runDir, 'journal.ndjson');
|
|
277
|
+
if (!existsSync(journalPath))
|
|
278
|
+
return { kind: 'stale-run', reason: 'missing' };
|
|
279
|
+
const events = readJournal(journalPath);
|
|
280
|
+
const snap = materialize(events);
|
|
281
|
+
// Target resolution: explicit nodeId > the run's blocked pointer > a node
|
|
282
|
+
// with an unconsumed retry reservation (a prior retry already cleared the
|
|
283
|
+
// blocked pointer — the idempotent repeat-call path).
|
|
284
|
+
const retryKeyFor = (id) => snap.nodes.get(id)?.effectiveInstanceId ?? id;
|
|
285
|
+
const nodeId = input.nodeId ??
|
|
286
|
+
snap.blockedNodeId ??
|
|
287
|
+
[...snap.nodes.keys()].find((id) => unconsumedRetryEvent(events, retryKeyFor(id)) !== undefined);
|
|
288
|
+
if (!nodeId)
|
|
289
|
+
return { kind: 'stale-run', reason: 'not-blocked' };
|
|
290
|
+
// An exhausted LOOP blocks the run too, but "retry an attempt" is the wrong
|
|
291
|
+
// verb for it — the recovery is a grant (+1 iteration). Route loudly.
|
|
292
|
+
if (snap.loops.has(nodeId))
|
|
293
|
+
return { kind: 'stale-run', reason: 'loop-node' };
|
|
294
|
+
const status = snap.nodes.get(nodeId)?.status;
|
|
295
|
+
if (status === 'pending') {
|
|
296
|
+
// An unconsumed retry reservation already reset this node — idempotent
|
|
297
|
+
// no-op (a second click / a CLI retry racing the card must not double-append).
|
|
298
|
+
const pendingRetry = unconsumedRetryEvent(events, retryKeyFor(nodeId));
|
|
299
|
+
if (pendingRetry) {
|
|
300
|
+
// The repeat-call is only "the same retry" when it references the attempt
|
|
301
|
+
// that retry was FOR — a stale older card is not an idempotent repeat.
|
|
302
|
+
if (input.expectedAttemptId && input.expectedAttemptId !== pendingRetry.previousAttemptId) {
|
|
303
|
+
return { kind: 'stale-run', reason: 'stale-attempt' };
|
|
304
|
+
}
|
|
305
|
+
return { kind: 'already-requested', nodeId };
|
|
306
|
+
}
|
|
307
|
+
return { kind: 'stale-run', reason: 'not-blocked' };
|
|
308
|
+
}
|
|
309
|
+
if (status !== 'blocked')
|
|
310
|
+
return { kind: 'stale-run', reason: 'not-blocked' };
|
|
311
|
+
// Constraint 5: a retry stays in the SAME instance — key attempt numbering by
|
|
312
|
+
// the blocked node's effective instance (`A#001`), not the bare nodeId, so the
|
|
313
|
+
// new attempt is `A#001/attempts/002` (NOT a new instance). Legacy runs with
|
|
314
|
+
// no instance fall back to nodeId.
|
|
315
|
+
const instanceId = snap.nodes.get(nodeId)?.effectiveInstanceId;
|
|
316
|
+
const attemptKey = instanceId ?? nodeId;
|
|
317
|
+
const previousAttemptId = latestAttemptIdFor(events, attemptKey);
|
|
318
|
+
if (!previousAttemptId)
|
|
319
|
+
return { kind: 'stale-run', reason: 'not-blocked' };
|
|
320
|
+
const info = blockedInfoFor(events, nodeId);
|
|
321
|
+
// Freshness gate (codex blocker): the click must target the CURRENTLY
|
|
322
|
+
// blocked attempt, not an earlier one whose card survived in the chat.
|
|
323
|
+
if (input.expectedAttemptId && input.expectedAttemptId !== info.attemptId) {
|
|
324
|
+
return { kind: 'stale-run', reason: 'stale-attempt' };
|
|
325
|
+
}
|
|
326
|
+
const nextAttemptId = nextAttemptIdFor(events, attemptKey);
|
|
327
|
+
// Runtime human-ask answer: persist the chosen option next to the asked
|
|
328
|
+
// attempt (answer.json) and carry its path on the retry event — buildInputs
|
|
329
|
+
// injects it into the next attempt as `{from:'human', name:'answer'}`. Plain
|
|
330
|
+
// blocked retries pass no answer and this whole block is skipped. Core
|
|
331
|
+
// validates membership against the current ask, because a card nonce proves
|
|
332
|
+
// freshness/integrity but not that `selected` is one of the authored options.
|
|
333
|
+
let answer;
|
|
334
|
+
if (input.answer) {
|
|
335
|
+
if (!info.ask) {
|
|
336
|
+
return { kind: 'stale-run', reason: 'invalid-answer' };
|
|
337
|
+
}
|
|
338
|
+
if ('selected' in input.answer) {
|
|
339
|
+
if (info.ask.freeText === true || !info.ask.options.includes(input.answer.selected)) {
|
|
340
|
+
return { kind: 'stale-run', reason: 'invalid-answer' };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
if (info.ask.freeText !== true || input.answer.text.trim() === '') {
|
|
345
|
+
return { kind: 'stale-run', reason: 'invalid-answer' };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const answerPath = join(runDir, previousAttemptId, GOAL_ANSWER_FILE);
|
|
349
|
+
const payload = 'text' in input.answer
|
|
350
|
+
? { text: input.answer.text, by: input.answer.by }
|
|
351
|
+
: { selected: input.answer.selected, by: input.answer.by };
|
|
352
|
+
atomicWriteJson(answerPath, payload);
|
|
353
|
+
answer = {
|
|
354
|
+
path: answerPath,
|
|
355
|
+
preview: answerPreview('text' in payload ? payload.text : payload.selected),
|
|
356
|
+
by: payload.by,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
appendEvent(journalPath, {
|
|
360
|
+
type: 'nodeRetryRequested',
|
|
361
|
+
nodeId,
|
|
362
|
+
...(instanceId ? { instanceId } : {}),
|
|
363
|
+
previousAttemptId,
|
|
364
|
+
nextAttemptId,
|
|
365
|
+
reason: 'blockedRetry',
|
|
366
|
+
previousErrorClass: info.errorClass,
|
|
367
|
+
previousErrorCode: info.errorCode,
|
|
368
|
+
...(answer ? { answer } : {}),
|
|
369
|
+
});
|
|
370
|
+
return { kind: 'requested', nodeId, previousAttemptId, nextAttemptId };
|
|
371
|
+
}
|
|
372
|
+
/** Grant +1 revisit budget after a run blocked on `REVISIT_BUDGET_EXHAUSTED`,
|
|
373
|
+
* then resume (the revisit analogue of a loop-iteration grant). Atomic
|
|
374
|
+
* "continue": append `revisitBudgetGranted` AND retry the blocked node so it
|
|
375
|
+
* re-attempts its revisit within the extended budget — one entry, like the
|
|
376
|
+
* card's one-click. Guards (菲菲 review):
|
|
377
|
+
* - the run MUST currently be blocked on a `REVISIT_BUDGET_EXHAUSTED` node
|
|
378
|
+
* (freshness + idempotency: after grant+retry the node is pending, so a
|
|
379
|
+
* repeat call is `not-budget-blocked` and adds NO further budget);
|
|
380
|
+
* - PAIR grant ⇒ both sourceNodeId+toNodeId, and sourceNodeId MUST be the
|
|
381
|
+
* blocked node; RUN grant ⇒ neither; a half-filled pair is rejected (never
|
|
382
|
+
* silently widened to a run grant);
|
|
383
|
+
* - `expectedAttemptId` (card passes it) must match the blocked attempt. */
|
|
384
|
+
export function requestRevisitGrant(baseDir, runId, input) {
|
|
385
|
+
const runDir = safeRunDir(baseDir, runId);
|
|
386
|
+
const journalPath = join(runDir, 'journal.ndjson');
|
|
387
|
+
if (!existsSync(journalPath))
|
|
388
|
+
return { kind: 'stale-run', reason: 'missing' };
|
|
389
|
+
// Reject a half-filled pair before touching state (never widen to run grant).
|
|
390
|
+
const hasSource = input.sourceNodeId !== undefined;
|
|
391
|
+
const hasTo = input.toNodeId !== undefined;
|
|
392
|
+
if (hasSource !== hasTo)
|
|
393
|
+
return { kind: 'invalid', reason: 'partial-pair' };
|
|
394
|
+
const pair = hasSource && hasTo;
|
|
395
|
+
// Freshness: must currently be blocked on a budget-exhausted node.
|
|
396
|
+
const events = readJournal(journalPath);
|
|
397
|
+
const snap = materialize(events);
|
|
398
|
+
const blockedNodeId = snap.blockedNodeId;
|
|
399
|
+
if (!blockedNodeId)
|
|
400
|
+
return { kind: 'stale-run', reason: 'not-budget-blocked' };
|
|
401
|
+
const info = blockedInfoFor(events, blockedNodeId);
|
|
402
|
+
if (info.errorCode !== 'REVISIT_BUDGET_EXHAUSTED')
|
|
403
|
+
return { kind: 'stale-run', reason: 'not-budget-blocked' };
|
|
404
|
+
if (input.expectedAttemptId && input.expectedAttemptId !== info.attemptId) {
|
|
405
|
+
return { kind: 'stale-run', reason: 'stale-attempt' };
|
|
406
|
+
}
|
|
407
|
+
// A pair grant must target the blocked node as its source.
|
|
408
|
+
if (pair && input.sourceNodeId !== blockedNodeId)
|
|
409
|
+
return { kind: 'invalid', reason: 'pair-source-mismatch' };
|
|
410
|
+
// Recovery-safe grant (code review): the node can be blocked on
|
|
411
|
+
// REVISIT_BUDGET_EXHAUSTED yet have budget ALREADY ok — that's the crash
|
|
412
|
+
// window where a prior call appended `revisitBudgetGranted` but died before the
|
|
413
|
+
// retry. Re-granting there would double the budget for one approval. So only
|
|
414
|
+
// append the grant while the budget is STILL exhausted; otherwise just resume
|
|
415
|
+
// (retry) the half-applied grant. Both paths are idempotent: after the retry
|
|
416
|
+
// the node is pending, so a further click is `not-budget-blocked`.
|
|
417
|
+
const stillExhausted = info.revisitTo
|
|
418
|
+
? !revisitBudgetStatus(events, blockedNodeId, info.revisitTo).ok
|
|
419
|
+
: true; // no recorded target → can't re-check; treat as exhausted (append once)
|
|
420
|
+
if (stillExhausted) {
|
|
421
|
+
appendEvent(journalPath, {
|
|
422
|
+
type: 'revisitBudgetGranted',
|
|
423
|
+
...(pair ? { sourceNodeId: input.sourceNodeId, toNodeId: input.toNodeId } : {}),
|
|
424
|
+
by: input.by,
|
|
425
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
// Resume: retry the blocked node so it re-runs and re-requests its revisit
|
|
429
|
+
// within the now-extended budget (reuses requestV3Retry's idempotency/guards).
|
|
430
|
+
const retry = requestV3Retry(baseDir, runId, { nodeId: blockedNodeId, expectedAttemptId: info.attemptId });
|
|
431
|
+
return { kind: 'granted', scope: pair ? 'pair' : 'run', retry };
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Grant ONE extra iteration to an exhausted-blocked loop (the loop analogue
|
|
435
|
+
* of `requestV3Retry` — daemon grant-card click and `botmux workflow grant`
|
|
436
|
+
* both land here). Same recovery-first discipline:
|
|
437
|
+
* 1. fresh `materialize(readJournal)` — the journal is the only truth.
|
|
438
|
+
* 2. the target loop must STILL be exhausted-blocked. An unconsumed grant
|
|
439
|
+
* (`pendingGrant`) → already-granted (idempotent, no second append).
|
|
440
|
+
* 3. `expectedIteration` (card clicks pass the card's iteration): the grant
|
|
441
|
+
* is only valid for the iteration the loop exhausted at — a stale card
|
|
442
|
+
* from an earlier exhaustion must not grant a second silent round
|
|
443
|
+
* (expectedAttemptId's lesson, ported). CLI omits it.
|
|
444
|
+
* 4. append `loopIterationGranted`; the caller re-drives (materialize folds
|
|
445
|
+
* the grant into a running loop → orchestrator starts iteration N+1).
|
|
446
|
+
*/
|
|
447
|
+
export function requestV3LoopGrant(baseDir, runId, input = {}) {
|
|
448
|
+
const runDir = safeRunDir(baseDir, runId);
|
|
449
|
+
const journalPath = join(runDir, 'journal.ndjson');
|
|
450
|
+
if (!existsSync(journalPath))
|
|
451
|
+
return { kind: 'stale-run', reason: 'missing' };
|
|
452
|
+
const events = readJournal(journalPath);
|
|
453
|
+
const snap = materialize(events);
|
|
454
|
+
// Target: explicit loopId > the run's blocked pointer when it IS a loop >
|
|
455
|
+
// a loop with an unconsumed grant (the idempotent repeat-call path).
|
|
456
|
+
const loopId = input.loopId ??
|
|
457
|
+
(snap.blockedNodeId && snap.loops.has(snap.blockedNodeId) ? snap.blockedNodeId : undefined) ??
|
|
458
|
+
[...snap.loops.entries()].find(([, ls]) => ls.pendingGrant)?.[0];
|
|
459
|
+
const ls = loopId ? snap.loops.get(loopId) : undefined;
|
|
460
|
+
if (!loopId || !ls)
|
|
461
|
+
return { kind: 'stale-run', reason: 'not-exhausted' };
|
|
462
|
+
if (input.expectedIteration !== undefined && input.expectedIteration !== ls.iteration) {
|
|
463
|
+
// Freshness gate: the card was rendered for the iteration the loop
|
|
464
|
+
// exhausted at; a newer exhaustion (or a consumed grant) invalidates it.
|
|
465
|
+
return { kind: 'stale-run', reason: 'stale-iteration' };
|
|
466
|
+
}
|
|
467
|
+
if (ls.pendingGrant)
|
|
468
|
+
return { kind: 'already-granted', loopId };
|
|
469
|
+
if (snap.nodes.get(loopId)?.status !== 'blocked' || ls.lastDecision !== 'exhausted') {
|
|
470
|
+
return { kind: 'stale-run', reason: 'not-exhausted' };
|
|
471
|
+
}
|
|
472
|
+
appendEvent(journalPath, {
|
|
473
|
+
type: 'loopIterationGranted', loopId, fromIteration: ls.iteration, by: input.by,
|
|
474
|
+
});
|
|
475
|
+
return { kind: 'granted', loopId, fromIteration: ls.iteration, nextIteration: ls.iteration + 1 };
|
|
476
|
+
}
|
|
477
|
+
/** The `nodeRetryRequested` for `key` whose reserved attempt has not yet been
|
|
478
|
+
* consumed by a matching `nodeDispatched` (undefined when none pending). `key`
|
|
479
|
+
* matches by `(instanceId ?? nodeId)` so a stale retry on an OLD instance isn't
|
|
480
|
+
* mistaken for the current instance's pending retry (constraint 5 / review #3). */
|
|
481
|
+
function unconsumedRetryEvent(events, key) {
|
|
482
|
+
const matches = (e) => (e.instanceId ?? e.nodeId) === key;
|
|
483
|
+
let pending;
|
|
484
|
+
for (const e of events) {
|
|
485
|
+
if (e.type === 'nodeRetryRequested' && matches(e))
|
|
486
|
+
pending = e;
|
|
487
|
+
else if (e.type === 'nodeDispatched' && matches(e) && e.attemptId === pending?.nextAttemptId) {
|
|
488
|
+
pending = undefined;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return pending;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* The daemon's v3 gate run-controller: an in-flight-guarded `drive(runId)`
|
|
495
|
+
* (mirrors v0.2's driveWorkflowRun re-entry) + a `coldAttach()` that re-arms
|
|
496
|
+
* pending gates on startup. Stateless except the in-flight set — recovery
|
|
497
|
+
* source is always the runDir.
|
|
498
|
+
*/
|
|
499
|
+
export function createV3GateRunner(deps) {
|
|
500
|
+
const inFlight = new Set();
|
|
501
|
+
const rerunRequested = new Set();
|
|
502
|
+
async function drive(runId) {
|
|
503
|
+
// Coalesce (codex blocker #2): if a drive is already in flight for this run,
|
|
504
|
+
// DON'T silently no-op — a click that resolved a gate + called driveDetached
|
|
505
|
+
// while the prior drive was busy (e.g. slow postCard) would otherwise be
|
|
506
|
+
// dropped and the run would stall at gateCleared/pending. Mark a rerun and
|
|
507
|
+
// let the active drive loop pick it up after it finishes.
|
|
508
|
+
if (inFlight.has(runId)) {
|
|
509
|
+
rerunRequested.add(runId);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
inFlight.add(runId);
|
|
513
|
+
try {
|
|
514
|
+
do {
|
|
515
|
+
rerunRequested.delete(runId); // clear before the run; a request DURING re-sets it
|
|
516
|
+
await driveV3Run(runId, {
|
|
517
|
+
baseDir: deps.baseDir,
|
|
518
|
+
loadBots: deps.loadBots,
|
|
519
|
+
makeRunNode: deps.makeRunNode,
|
|
520
|
+
validateManifest: deps.validateManifest,
|
|
521
|
+
maxParallel: deps.maxParallel,
|
|
522
|
+
postGateCard: (binding, gate, rid) => deps.postCard(binding, gate, rid),
|
|
523
|
+
postBlockedCard: deps.postBlockedCard,
|
|
524
|
+
postLoopGrantCard: deps.postLoopGrantCard,
|
|
525
|
+
postRevisitGrantCard: deps.postRevisitGrantCard,
|
|
526
|
+
onTerminal: (rid, outcome, binding) => deps.notifyTerminal ? deps.notifyTerminal(binding, rid, outcome) : Promise.resolve(),
|
|
527
|
+
});
|
|
528
|
+
} while (rerunRequested.has(runId));
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
deps.onError?.(runId, err);
|
|
532
|
+
}
|
|
533
|
+
finally {
|
|
534
|
+
inFlight.delete(runId);
|
|
535
|
+
rerunRequested.delete(runId);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/** Fire-and-forget drive (card-click / start IPC call this). */
|
|
539
|
+
function driveDetached(runId) {
|
|
540
|
+
void drive(runId);
|
|
541
|
+
}
|
|
542
|
+
async function coldAttach(ownerLarkAppId) {
|
|
543
|
+
let recs = [];
|
|
544
|
+
try {
|
|
545
|
+
recs = reconcileV3PendingGates(deps.baseDir, ownerLarkAppId);
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
deps.onError?.('(cold-attach)', err);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
for (const rec of recs) {
|
|
552
|
+
if (rec.binding) {
|
|
553
|
+
// When `resume` is true a self-healed gate lets the run be re-driven, and
|
|
554
|
+
// that drive (driveV3Run, suspend mode) re-posts every STILL-pending gate
|
|
555
|
+
// itself. Posting rec.repost here too would double-send the pending gate's
|
|
556
|
+
// card in the "one gate healed + another still pending" case, so skip it
|
|
557
|
+
// and let the drive own the re-post (codex nit #10). The blocked/loop/
|
|
558
|
+
// revisit grant reposts always carry resume:false, so they're unaffected.
|
|
559
|
+
if (!rec.resume) {
|
|
560
|
+
for (const gate of rec.repost) {
|
|
561
|
+
try {
|
|
562
|
+
await deps.postCard(rec.binding, gate, rec.runId);
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
deps.onError?.(rec.runId, err);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (rec.repostBlocked && deps.postBlockedCard) {
|
|
570
|
+
try {
|
|
571
|
+
await deps.postBlockedCard(rec.binding, rec.repostBlocked, rec.runId);
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
deps.onError?.(rec.runId, err);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (rec.repostLoopGrant && deps.postLoopGrantCard) {
|
|
578
|
+
try {
|
|
579
|
+
await deps.postLoopGrantCard(rec.binding, rec.repostLoopGrant, rec.runId);
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
deps.onError?.(rec.runId, err);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (rec.repostRevisitGrant && deps.postRevisitGrantCard) {
|
|
586
|
+
try {
|
|
587
|
+
await deps.postRevisitGrantCard(rec.binding, rec.repostRevisitGrant, rec.runId);
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
deps.onError?.(rec.runId, err);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (rec.resume)
|
|
595
|
+
driveDetached(rec.runId);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return { drive, driveDetached, coldAttach };
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Cold-attach reconcile (daemon startup, codex review #2/#3). Finds v3 runs
|
|
602
|
+
* suspended at a humanGate and reconciles the journal↔wait-file atomic window
|
|
603
|
+
* BOTH ways:
|
|
604
|
+
* - node `gateWaiting` + wait file MISSING (crash between the `gateDispatched`
|
|
605
|
+
* append and `writePendingWait`) → re-create the pending wait from the
|
|
606
|
+
* dag's `humanGate.prompt`, then repost a card.
|
|
607
|
+
* - node `gateWaiting` + wait RESOLVED (crash between `resolveWait` and the
|
|
608
|
+
* `gateResolved` append) → append the missing `gateResolved` → resume.
|
|
609
|
+
* - node `gateWaiting` + wait pending → just repost a card.
|
|
610
|
+
* Skips terminal runs. Pure file IO + journal append — the daemon decides what
|
|
611
|
+
* to post / drive from the returned list.
|
|
612
|
+
*/
|
|
613
|
+
export function reconcileV3PendingGates(baseDir = defaultBaseDir(), ownerLarkAppId) {
|
|
614
|
+
if (!existsSync(baseDir))
|
|
615
|
+
return [];
|
|
616
|
+
const out = [];
|
|
617
|
+
for (const runId of readdirSync(baseDir)) {
|
|
618
|
+
if (!isValidRunId(runId))
|
|
619
|
+
continue; // skip non-run dirs / unsafe names (codex #2)
|
|
620
|
+
const runDir = join(baseDir, runId);
|
|
621
|
+
try {
|
|
622
|
+
if (!statSync(runDir).isDirectory())
|
|
623
|
+
continue;
|
|
624
|
+
const journalPath = join(runDir, 'journal.ndjson');
|
|
625
|
+
if (!existsSync(journalPath))
|
|
626
|
+
continue;
|
|
627
|
+
const events = readJournal(journalPath);
|
|
628
|
+
const snap = materialize(events);
|
|
629
|
+
if (snap.runStatus === 'succeeded' || snap.runStatus === 'failed')
|
|
630
|
+
continue;
|
|
631
|
+
if (snap.runStatus === 'blocked') {
|
|
632
|
+
// Blocked run: repost the recovery card (covers the crash window
|
|
633
|
+
// between the runBlocked append and the original card send) — grant
|
|
634
|
+
// card for an exhausted loop, retry card for a blocked node. Owner-
|
|
635
|
+
// filtered like gates; binding-less (CLI/dev) runs are left alone.
|
|
636
|
+
const grill = readGrillState(runDir);
|
|
637
|
+
const binding = grill?.chatBinding;
|
|
638
|
+
if (ownerLarkAppId && binding?.larkAppId !== ownerLarkAppId)
|
|
639
|
+
continue;
|
|
640
|
+
if (!binding || !snap.blockedNodeId)
|
|
641
|
+
continue;
|
|
642
|
+
if (snap.loops.has(snap.blockedNodeId)) {
|
|
643
|
+
const info = loopExhaustedInfoFor(events, snap.blockedNodeId);
|
|
644
|
+
if (grill?.dagPath && existsSync(grill.dagPath)) {
|
|
645
|
+
try {
|
|
646
|
+
const loopNode = loadDag(grill.dagPath).nodes.find((n) => n.id === snap.blockedNodeId);
|
|
647
|
+
if (loopNode && isLoopNode(loopNode))
|
|
648
|
+
info.maxIterations = loopNode.maxIterations;
|
|
649
|
+
}
|
|
650
|
+
catch { /* display-only enrichment — card renders without it */ }
|
|
651
|
+
}
|
|
652
|
+
out.push({ runId, runDir, binding, repost: [], repostLoopGrant: info, resume: false });
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
// Revisit-budget block → grant card; otherwise the plain retry card.
|
|
656
|
+
const revisitBudget = revisitBudgetBlockedInfoFor(events, snap.blockedNodeId);
|
|
657
|
+
if (revisitBudget) {
|
|
658
|
+
out.push({ runId, runDir, binding, repost: [], repostRevisitGrant: revisitBudget, resume: false });
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
out.push({
|
|
662
|
+
runId, runDir, binding,
|
|
663
|
+
repost: [],
|
|
664
|
+
repostBlocked: blockedInfoFor(events, snap.blockedNodeId),
|
|
665
|
+
resume: false,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
// runStatus === 'running'
|
|
672
|
+
const rec = reconcileOneRun(runId, runDir, journalPath, snap, ownerLarkAppId);
|
|
673
|
+
if (rec) {
|
|
674
|
+
out.push(rec);
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
// No gate to reconcile. If nothing is (phantom-)running, the run died in
|
|
678
|
+
// a resumable spot — e.g. crash right after a `nodeRetryRequested` append
|
|
679
|
+
// (before the redrive) or right after start — so re-drive it. Runs with
|
|
680
|
+
// phantom `running` nodes are the dangling-attempt recovery gap (worker
|
|
681
|
+
// fencing backlog) and are deliberately left alone.
|
|
682
|
+
//
|
|
683
|
+
// Composite LOOP nodes are excluded from the phantom check (codex loop
|
|
684
|
+
// review blocker): a loop is 'running' in PURE CONTROL states with no
|
|
685
|
+
// worker behind it — right after loopStarted / a continue-decision / a
|
|
686
|
+
// grant, the runtime's next tick derives the follow-up from the journal.
|
|
687
|
+
// A crash in those windows must resume, or the run sticks forever (most
|
|
688
|
+
// reproducible: grant click → loopIterationGranted appended → crash
|
|
689
|
+
// before redrive). Body INSTANCES still count as real in-flight.
|
|
690
|
+
const hasRunning = [...snap.nodes.entries()].some(([id, s]) => s.status === 'running' && !snap.loops.has(id));
|
|
691
|
+
if (!hasRunning) {
|
|
692
|
+
const grill = readGrillState(runDir);
|
|
693
|
+
const binding = grill?.chatBinding;
|
|
694
|
+
if (ownerLarkAppId && binding?.larkAppId !== ownerLarkAppId)
|
|
695
|
+
continue;
|
|
696
|
+
if (!binding)
|
|
697
|
+
continue; // CLI/dev runs are not the daemon's to adopt
|
|
698
|
+
out.push({ runId, runDir, binding, repost: [], resume: true });
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
// best-effort (codex #3): a single corrupt run (torn journal / bad
|
|
703
|
+
// grill.state / IO error) must not kill the whole cold-attach scan.
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return out;
|
|
708
|
+
}
|
|
709
|
+
function reconcileOneRun(runId, runDir, journalPath, snap, ownerLarkAppId) {
|
|
710
|
+
const gateWaitingNodes = [...snap.nodes.entries()]
|
|
711
|
+
.filter(([, s]) => s.status === 'gateWaiting')
|
|
712
|
+
.map(([id]) => id);
|
|
713
|
+
if (gateWaitingNodes.length === 0)
|
|
714
|
+
return undefined;
|
|
715
|
+
const grill = readGrillState(runDir); // defensive: undefined on corrupt (won't throw)
|
|
716
|
+
const binding = grill?.chatBinding;
|
|
717
|
+
// Multi-daemon owner filter (codex blocker #1): each bot daemon must only
|
|
718
|
+
// touch runs bound to ITS larkAppId — otherwise every online daemon re-posts
|
|
719
|
+
// / resumes the same pending gate. A run with no binding (CLI/dev) or a
|
|
720
|
+
// different owner is left for the owning daemon (or nobody).
|
|
721
|
+
if (ownerLarkAppId && binding?.larkAppId !== ownerLarkAppId)
|
|
722
|
+
return undefined;
|
|
723
|
+
// dag (for humanGate.prompt when re-creating a missing wait).
|
|
724
|
+
const dagNodeGate = new Map();
|
|
725
|
+
if (grill?.dagPath && existsSync(grill.dagPath)) {
|
|
726
|
+
try {
|
|
727
|
+
for (const n of loadDag(grill.dagPath).nodes) {
|
|
728
|
+
if (n.humanGate?.prompt)
|
|
729
|
+
dagNodeGate.set(n.id, normalizeGateWaitInput(n.humanGate));
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
/* dag unreadable — fall back to a generic prompt below */
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const repost = [];
|
|
737
|
+
let resume = false;
|
|
738
|
+
for (const nodeId of gateWaitingNodes) {
|
|
739
|
+
// Instance-level waitId mirrors startGate so recovery reads the SAME wait
|
|
740
|
+
// file the dispatch wrote (stale-card protection).
|
|
741
|
+
const instanceId = snap.nodes.get(nodeId)?.effectiveInstanceId;
|
|
742
|
+
const waitId = `${instanceId ?? nodeId}-gate`;
|
|
743
|
+
let wait = readWait(runDir, waitId);
|
|
744
|
+
if (!wait) {
|
|
745
|
+
const gate = dagNodeGate.get(nodeId) ?? normalizeGateWaitInput({ prompt: '(humanGate — 等待人工审批)' });
|
|
746
|
+
wait = writePendingWait(runDir, { waitId, nodeId, ...(instanceId ? { instanceId } : {}), ...gate });
|
|
747
|
+
}
|
|
748
|
+
if (wait.status === 'pending') {
|
|
749
|
+
repost.push({
|
|
750
|
+
nodeId,
|
|
751
|
+
waitId,
|
|
752
|
+
prompt: wait.prompt,
|
|
753
|
+
options: wait.options,
|
|
754
|
+
approveOptions: wait.approveOptions,
|
|
755
|
+
approvers: wait.approvers,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
// resolved wait but node still gateWaiting → journal lost gateResolved → heal.
|
|
760
|
+
// nodeId from the wait file (authoritative, codex #1).
|
|
761
|
+
appendEvent(journalPath, {
|
|
762
|
+
type: 'gateResolved',
|
|
763
|
+
nodeId: wait.nodeId,
|
|
764
|
+
...(snap.nodes.get(wait.nodeId)?.effectiveInstanceId ? { instanceId: snap.nodes.get(wait.nodeId).effectiveInstanceId } : {}),
|
|
765
|
+
waitId,
|
|
766
|
+
resolution: wait.status,
|
|
767
|
+
by: wait.by ?? 'system',
|
|
768
|
+
selected: wait.selected,
|
|
769
|
+
});
|
|
770
|
+
resume = true;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return { runId, runDir, binding, repost, resume };
|
|
774
|
+
}
|
|
775
|
+
//# sourceMappingURL=daemon-run.js.map
|