botmux 2.33.0 → 2.34.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/README.en.md +12 -1
- package/README.md +45 -1
- package/dist/adapters/cli/claude-code.d.ts.map +1 -1
- package/dist/adapters/cli/claude-code.js +11 -0
- package/dist/adapters/cli/claude-code.js.map +1 -1
- package/dist/cli/bots-list-output.d.ts +21 -0
- package/dist/cli/bots-list-output.d.ts.map +1 -0
- package/dist/cli/bots-list-output.js +23 -0
- package/dist/cli/bots-list-output.js.map +1 -0
- package/dist/cli/workflow.d.ts +13 -0
- package/dist/cli/workflow.d.ts.map +1 -0
- package/dist/cli/workflow.js +781 -0
- package/dist/cli/workflow.js.map +1 -0
- package/dist/cli.js +69 -14
- package/dist/cli.js.map +1 -1
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +219 -6
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/session-manager.d.ts +6 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +22 -12
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/worker-pool.d.ts +13 -0
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +100 -6
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts +3 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +884 -3
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/auth.d.ts +36 -0
- package/dist/dashboard/auth.d.ts.map +1 -1
- package/dist/dashboard/auth.js +22 -0
- package/dist/dashboard/auth.js.map +1 -1
- package/dist/dashboard/web/app.js +20 -1
- package/dist/dashboard/web/app.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +356 -0
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/workflow-catalog.d.ts +2 -0
- package/dist/dashboard/web/workflow-catalog.d.ts.map +1 -0
- package/dist/dashboard/web/workflow-catalog.js +323 -0
- package/dist/dashboard/web/workflow-catalog.js.map +1 -0
- package/dist/dashboard/web/workflows.d.ts +2 -0
- package/dist/dashboard/web/workflows.d.ts.map +1 -0
- package/dist/dashboard/web/workflows.js +1618 -0
- package/dist/dashboard/web/workflows.js.map +1 -0
- package/dist/dashboard/workflow-api.d.ts +23 -0
- package/dist/dashboard/workflow-api.d.ts.map +1 -0
- package/dist/dashboard/workflow-api.js +463 -0
- package/dist/dashboard/workflow-api.js.map +1 -0
- package/dist/dashboard-web/app.js +494 -199
- package/dist/dashboard-web/index.html +1 -0
- package/dist/dashboard-web/style.css +160 -6
- package/dist/dashboard-web/terminal-replay.html +227 -0
- package/dist/dashboard.js +29 -12
- package/dist/dashboard.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +12 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +12 -0
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts +3 -0
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +27 -1
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +19 -2
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +21 -2
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/workflow-card-handler.d.ts +50 -0
- package/dist/im/lark/workflow-card-handler.d.ts.map +1 -0
- package/dist/im/lark/workflow-card-handler.js +152 -0
- package/dist/im/lark/workflow-card-handler.js.map +1 -0
- package/dist/im/lark/workflow-cards.d.ts +46 -0
- package/dist/im/lark/workflow-cards.d.ts.map +1 -0
- package/dist/im/lark/workflow-cards.js +226 -0
- package/dist/im/lark/workflow-cards.js.map +1 -0
- package/dist/im/lark/workflow-progress-card.d.ts +76 -0
- package/dist/im/lark/workflow-progress-card.d.ts.map +1 -0
- package/dist/im/lark/workflow-progress-card.js +279 -0
- package/dist/im/lark/workflow-progress-card.js.map +1 -0
- package/dist/im/lark/workflow-slash-command.d.ts +92 -0
- package/dist/im/lark/workflow-slash-command.d.ts.map +1 -0
- package/dist/im/lark/workflow-slash-command.js +185 -0
- package/dist/im/lark/workflow-slash-command.js.map +1 -0
- package/dist/services/group-creator.d.ts.map +1 -1
- package/dist/services/group-creator.js +17 -4
- package/dist/services/group-creator.js.map +1 -1
- package/dist/services/groups-store.d.ts +11 -0
- package/dist/services/groups-store.d.ts.map +1 -1
- package/dist/services/groups-store.js +26 -0
- package/dist/services/groups-store.js.map +1 -1
- package/dist/services/jsonl-cursor.d.ts +12 -0
- package/dist/services/jsonl-cursor.d.ts.map +1 -0
- package/dist/services/jsonl-cursor.js +45 -0
- package/dist/services/jsonl-cursor.js.map +1 -0
- package/dist/services/schedule-store.d.ts +35 -0
- package/dist/services/schedule-store.d.ts.map +1 -1
- package/dist/services/schedule-store.js +108 -1
- package/dist/services/schedule-store.js.map +1 -1
- package/dist/skills/definitions.d.ts.map +1 -1
- package/dist/skills/definitions.js +399 -0
- package/dist/skills/definitions.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/cli-usage-limit.d.ts.map +1 -1
- package/dist/utils/cli-usage-limit.js +4 -0
- package/dist/utils/cli-usage-limit.js.map +1 -1
- package/dist/worker.js +118 -14
- package/dist/worker.js.map +1 -1
- package/dist/workflows/attempt-resume.d.ts +114 -0
- package/dist/workflows/attempt-resume.d.ts.map +1 -0
- package/dist/workflows/attempt-resume.js +385 -0
- package/dist/workflows/attempt-resume.js.map +1 -0
- package/dist/workflows/attempt-terminal.d.ts +21 -0
- package/dist/workflows/attempt-terminal.d.ts.map +1 -0
- package/dist/workflows/attempt-terminal.js +7 -0
- package/dist/workflows/attempt-terminal.js.map +1 -0
- package/dist/workflows/blob.d.ts +27 -0
- package/dist/workflows/blob.d.ts.map +1 -0
- package/dist/workflows/blob.js +39 -0
- package/dist/workflows/blob.js.map +1 -0
- package/dist/workflows/cancel-run.d.ts +45 -0
- package/dist/workflows/cancel-run.d.ts.map +1 -0
- package/dist/workflows/cancel-run.js +99 -0
- package/dist/workflows/cancel-run.js.map +1 -0
- package/dist/workflows/cancel.d.ts +111 -0
- package/dist/workflows/cancel.d.ts.map +1 -0
- package/dist/workflows/cancel.js +120 -0
- package/dist/workflows/cancel.js.map +1 -0
- package/dist/workflows/catalog.d.ts +60 -0
- package/dist/workflows/catalog.d.ts.map +1 -0
- package/dist/workflows/catalog.js +119 -0
- package/dist/workflows/catalog.js.map +1 -0
- package/dist/workflows/cold-attach.d.ts +30 -0
- package/dist/workflows/cold-attach.d.ts.map +1 -0
- package/dist/workflows/cold-attach.js +40 -0
- package/dist/workflows/cold-attach.js.map +1 -0
- package/dist/workflows/cold-scan.d.ts +21 -0
- package/dist/workflows/cold-scan.d.ts.map +1 -0
- package/dist/workflows/cold-scan.js +70 -0
- package/dist/workflows/cold-scan.js.map +1 -0
- package/dist/workflows/daemon-spawn.d.ts +117 -0
- package/dist/workflows/daemon-spawn.d.ts.map +1 -0
- package/dist/workflows/daemon-spawn.js +551 -0
- package/dist/workflows/daemon-spawn.js.map +1 -0
- package/dist/workflows/definition.d.ts +1309 -0
- package/dist/workflows/definition.d.ts.map +1 -0
- package/dist/workflows/definition.js +334 -0
- package/dist/workflows/definition.js.map +1 -0
- package/dist/workflows/effect-input.d.ts +4 -0
- package/dist/workflows/effect-input.d.ts.map +1 -0
- package/dist/workflows/effect-input.js +18 -0
- package/dist/workflows/effect-input.js.map +1 -0
- package/dist/workflows/events/append.d.ts +77 -0
- package/dist/workflows/events/append.d.ts.map +1 -0
- package/dist/workflows/events/append.js +214 -0
- package/dist/workflows/events/append.js.map +1 -0
- package/dist/workflows/events/idempotency.d.ts +77 -0
- package/dist/workflows/events/idempotency.d.ts.map +1 -0
- package/dist/workflows/events/idempotency.js +116 -0
- package/dist/workflows/events/idempotency.js.map +1 -0
- package/dist/workflows/events/index.d.ts +7 -0
- package/dist/workflows/events/index.d.ts.map +1 -0
- package/dist/workflows/events/index.js +7 -0
- package/dist/workflows/events/index.js.map +1 -0
- package/dist/workflows/events/payloads.d.ts +917 -0
- package/dist/workflows/events/payloads.d.ts.map +1 -0
- package/dist/workflows/events/payloads.js +337 -0
- package/dist/workflows/events/payloads.js.map +1 -0
- package/dist/workflows/events/replay.d.ts +238 -0
- package/dist/workflows/events/replay.d.ts.map +1 -0
- package/dist/workflows/events/replay.js +608 -0
- package/dist/workflows/events/replay.js.map +1 -0
- package/dist/workflows/events/schema.d.ts +5242 -0
- package/dist/workflows/events/schema.d.ts.map +1 -0
- package/dist/workflows/events/schema.js +295 -0
- package/dist/workflows/events/schema.js.map +1 -0
- package/dist/workflows/events/types.d.ts +34 -0
- package/dist/workflows/events/types.d.ts.map +1 -0
- package/dist/workflows/events/types.js +2 -0
- package/dist/workflows/events/types.js.map +1 -0
- package/dist/workflows/fanout.d.ts +36 -0
- package/dist/workflows/fanout.d.ts.map +1 -0
- package/dist/workflows/fanout.js +114 -0
- package/dist/workflows/fanout.js.map +1 -0
- package/dist/workflows/hostExecutors/botmux-schedule.d.ts +41 -0
- package/dist/workflows/hostExecutors/botmux-schedule.d.ts.map +1 -0
- package/dist/workflows/hostExecutors/botmux-schedule.js +121 -0
- package/dist/workflows/hostExecutors/botmux-schedule.js.map +1 -0
- package/dist/workflows/hostExecutors/feishu-im.d.ts +12 -0
- package/dist/workflows/hostExecutors/feishu-im.d.ts.map +1 -0
- package/dist/workflows/hostExecutors/feishu-im.js +49 -0
- package/dist/workflows/hostExecutors/feishu-im.js.map +1 -0
- package/dist/workflows/hostExecutors/feishu-reply.d.ts +24 -0
- package/dist/workflows/hostExecutors/feishu-reply.d.ts.map +1 -0
- package/dist/workflows/hostExecutors/feishu-reply.js +88 -0
- package/dist/workflows/hostExecutors/feishu-reply.js.map +1 -0
- package/dist/workflows/hostExecutors/feishu-send.d.ts +23 -0
- package/dist/workflows/hostExecutors/feishu-send.d.ts.map +1 -0
- package/dist/workflows/hostExecutors/feishu-send.js +124 -0
- package/dist/workflows/hostExecutors/feishu-send.js.map +1 -0
- package/dist/workflows/hostExecutors/index.d.ts +8 -0
- package/dist/workflows/hostExecutors/index.d.ts.map +1 -0
- package/dist/workflows/hostExecutors/index.js +8 -0
- package/dist/workflows/hostExecutors/index.js.map +1 -0
- package/dist/workflows/hostExecutors/protocol.d.ts +42 -0
- package/dist/workflows/hostExecutors/protocol.d.ts.map +1 -0
- package/dist/workflows/hostExecutors/protocol.js +181 -0
- package/dist/workflows/hostExecutors/protocol.js.map +1 -0
- package/dist/workflows/hostExecutors/registry.d.ts +10 -0
- package/dist/workflows/hostExecutors/registry.d.ts.map +1 -0
- package/dist/workflows/hostExecutors/registry.js +36 -0
- package/dist/workflows/hostExecutors/registry.js.map +1 -0
- package/dist/workflows/hostExecutors/types.d.ts +78 -0
- package/dist/workflows/hostExecutors/types.d.ts.map +1 -0
- package/dist/workflows/hostExecutors/types.js +2 -0
- package/dist/workflows/hostExecutors/types.js.map +1 -0
- package/dist/workflows/loader.d.ts +16 -0
- package/dist/workflows/loader.d.ts.map +1 -0
- package/dist/workflows/loader.js +56 -0
- package/dist/workflows/loader.js.map +1 -0
- package/dist/workflows/loop.d.ts +50 -0
- package/dist/workflows/loop.d.ts.map +1 -0
- package/dist/workflows/loop.js +350 -0
- package/dist/workflows/loop.js.map +1 -0
- package/dist/workflows/ops-projection.d.ts +168 -0
- package/dist/workflows/ops-projection.d.ts.map +1 -0
- package/dist/workflows/ops-projection.js +707 -0
- package/dist/workflows/ops-projection.js.map +1 -0
- package/dist/workflows/orchestrator.d.ts +107 -0
- package/dist/workflows/orchestrator.d.ts.map +1 -0
- package/dist/workflows/orchestrator.js +197 -0
- package/dist/workflows/orchestrator.js.map +1 -0
- package/dist/workflows/output-binding.d.ts +70 -0
- package/dist/workflows/output-binding.d.ts.map +1 -0
- package/dist/workflows/output-binding.js +265 -0
- package/dist/workflows/output-binding.js.map +1 -0
- package/dist/workflows/params.d.ts +61 -0
- package/dist/workflows/params.d.ts.map +1 -0
- package/dist/workflows/params.js +195 -0
- package/dist/workflows/params.js.map +1 -0
- package/dist/workflows/resume.d.ts +263 -0
- package/dist/workflows/resume.d.ts.map +1 -0
- package/dist/workflows/resume.js +808 -0
- package/dist/workflows/resume.js.map +1 -0
- package/dist/workflows/run-id.d.ts +2 -0
- package/dist/workflows/run-id.d.ts.map +1 -0
- package/dist/workflows/run-id.js +7 -0
- package/dist/workflows/run-id.js.map +1 -0
- package/dist/workflows/run-init.d.ts +48 -0
- package/dist/workflows/run-init.d.ts.map +1 -0
- package/dist/workflows/run-init.js +99 -0
- package/dist/workflows/run-init.js.map +1 -0
- package/dist/workflows/runs-dir.d.ts +4 -0
- package/dist/workflows/runs-dir.d.ts.map +1 -0
- package/dist/workflows/runs-dir.js +15 -0
- package/dist/workflows/runs-dir.js.map +1 -0
- package/dist/workflows/runtime.d.ts +211 -0
- package/dist/workflows/runtime.d.ts.map +1 -0
- package/dist/workflows/runtime.js +594 -0
- package/dist/workflows/runtime.js.map +1 -0
- package/dist/workflows/spawn-bot.d.ts +165 -0
- package/dist/workflows/spawn-bot.d.ts.map +1 -0
- package/dist/workflows/spawn-bot.js +215 -0
- package/dist/workflows/spawn-bot.js.map +1 -0
- package/dist/workflows/system.d.ts +49 -0
- package/dist/workflows/system.d.ts.map +1 -0
- package/dist/workflows/system.js +48 -0
- package/dist/workflows/system.js.map +1 -0
- package/dist/workflows/trigger-run.d.ts +70 -0
- package/dist/workflows/trigger-run.d.ts.map +1 -0
- package/dist/workflows/trigger-run.js +88 -0
- package/dist/workflows/trigger-run.js.map +1 -0
- package/dist/workflows/wait.d.ts +120 -0
- package/dist/workflows/wait.d.ts.map +1 -0
- package/dist/workflows/wait.js +181 -0
- package/dist/workflows/wait.js.map +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume + reconcile algorithm (events doc v0.1.2 §4.3 + §4.3.1).
|
|
3
|
+
*
|
|
4
|
+
* Entry point for daemon restart / hand-off. Walks the event log,
|
|
5
|
+
* replays a snapshot, then drives reconcile decisions for each dangling
|
|
6
|
+
* `effectAttempted` and writes terminal events for `pure skill`
|
|
7
|
+
* activities that crashed mid-flight (workerLost path).
|
|
8
|
+
*
|
|
9
|
+
* Step 7 boundaries:
|
|
10
|
+
* - Resume DOES NOT execute activity logic; reconcile uses provider
|
|
11
|
+
* capabilities (`readOnlyLookup` / `idempotentSubmit`) to decide
|
|
12
|
+
* terminal state without re-issuing user-visible work beyond what
|
|
13
|
+
* idempotency guarantees.
|
|
14
|
+
* - Resume DOES NOT decide retry policy. A `freshRetry` decision
|
|
15
|
+
* leaves the attempt dangling — the scheduler (Step 8+) is
|
|
16
|
+
* responsible for spawning the actual replacement attempt.
|
|
17
|
+
* - Dangling waits are left alone (waiting for external signal).
|
|
18
|
+
*
|
|
19
|
+
* Round 1 fixes (codex review of `1d14081`):
|
|
20
|
+
* F1 — replay surfaces the latest reconcileResult per attempt; resume
|
|
21
|
+
* consumes it before re-running the decision tree, so a crash
|
|
22
|
+
* between reconcileResult and the terminal event is recoverable.
|
|
23
|
+
* F2 — reconcilers receive the materialized effect input via the
|
|
24
|
+
* caller-supplied `loadEffectInput` callback. Reconcilers that
|
|
25
|
+
* require input (e.g. Feishu — chatId/rootMessageId/content can't
|
|
26
|
+
* be reconstructed from idempotencyKey alone) fail explicitly
|
|
27
|
+
* when input is unrecoverable.
|
|
28
|
+
* F3 — `retryable` failures from idempotentSubmit do NOT terminate
|
|
29
|
+
* the attempt; the activity stays dangling and is surfaced in
|
|
30
|
+
* `ResumeResult.transientFailures` for the caller to retry.
|
|
31
|
+
* F4 — `resumeStarted` is written ONLY after a preflight validates
|
|
32
|
+
* the log is replayable; bad inputs throw without polluting the
|
|
33
|
+
* run event log.
|
|
34
|
+
*/
|
|
35
|
+
import { computeInputHash } from './events/idempotency.js';
|
|
36
|
+
import { replay } from './events/replay.js';
|
|
37
|
+
// ─── Resume orchestrator ────────────────────────────────────────────────────
|
|
38
|
+
export async function resume(ctx) {
|
|
39
|
+
if (ctx.runId !== ctx.log.runId) {
|
|
40
|
+
throw new Error(`resume: ctx.runId (${ctx.runId}) does not match log.runId (${ctx.log.runId})`);
|
|
41
|
+
}
|
|
42
|
+
const now = ctx.now ?? Date.now;
|
|
43
|
+
// F4: Preflight BEFORE writing resumeStarted. Bad logs (empty / no
|
|
44
|
+
// runCreated / cross-runId contamination) throw without polluting the
|
|
45
|
+
// run event log — audit goes to the daemon logger, not the canonical
|
|
46
|
+
// per-run event stream.
|
|
47
|
+
const preEvents = await ctx.log.readAll();
|
|
48
|
+
if (preEvents.length === 0) {
|
|
49
|
+
throw new Error(`resume(${ctx.runId}): cannot resume an empty event log — no runCreated to project from.`);
|
|
50
|
+
}
|
|
51
|
+
if (preEvents[0].type !== 'runCreated') {
|
|
52
|
+
throw new Error(`resume(${ctx.runId}): first event must be runCreated, got ${preEvents[0].type} (corrupt log; not appending resumeStarted).`);
|
|
53
|
+
}
|
|
54
|
+
// We let `replay` enforce cross-runId, but check up front so the
|
|
55
|
+
// diagnostic is colocated with the preflight.
|
|
56
|
+
if (preEvents[0].runId !== ctx.runId) {
|
|
57
|
+
throw new Error(`resume(${ctx.runId}): runCreated.runId is ${preEvents[0].runId}, log/ctx are ${ctx.runId} (corrupt log; not appending resumeStarted).`);
|
|
58
|
+
}
|
|
59
|
+
// Preflight passed — now write the audit entry.
|
|
60
|
+
const resumeStartedEvent = (await ctx.log.append({
|
|
61
|
+
runId: ctx.runId,
|
|
62
|
+
type: 'resumeStarted',
|
|
63
|
+
actor: 'system',
|
|
64
|
+
payload: {
|
|
65
|
+
daemonId: ctx.daemonId,
|
|
66
|
+
lastSeenEventId: preEvents[preEvents.length - 1].eventId,
|
|
67
|
+
},
|
|
68
|
+
}));
|
|
69
|
+
// Re-read so the snapshot includes the resumeStarted (replay treats
|
|
70
|
+
// it as a no-op projection — keeping the read consistent).
|
|
71
|
+
const allEvents = await ctx.log.readAll();
|
|
72
|
+
const snapshot = replay(allEvents);
|
|
73
|
+
// Step 9: cancel recovery — cancelRequested landed but no terminal.
|
|
74
|
+
// Spec §2.5: cancel is the authoritative terminal REASON, but when
|
|
75
|
+
// the cancelled attempt also has a dangling `effectAttempted` we
|
|
76
|
+
// must FIRST run reconcile to capture provider evidence (codex Step 9
|
|
77
|
+
// round 1 finding 1). Skipping reconcile would write activityCanceled
|
|
78
|
+
// without ever observing whether the provider actually performed the
|
|
79
|
+
// side effect — which is the difference between "we cancelled a no-op"
|
|
80
|
+
// and "we cancelled a successful submit", and recovery can't replay
|
|
81
|
+
// that distinction later. We still write `activityCanceled` for the
|
|
82
|
+
// common cases (completedByIdempotentSubmit / freshRetry) so cancel
|
|
83
|
+
// remains the terminal reason; only `manual` reconcile decisions
|
|
84
|
+
// escalate to `activityFailed{manual}` because the provider state is
|
|
85
|
+
// unknown and pretending otherwise would lie about the cancel outcome.
|
|
86
|
+
//
|
|
87
|
+
// Cancel runs first so the subsequent loops can skip its activities.
|
|
88
|
+
const cancelRecoveryOutcomes = [];
|
|
89
|
+
const transientFailures = [];
|
|
90
|
+
const effectAttemptedSet = new Set(snapshot.danglingEffectAttempted);
|
|
91
|
+
for (const activityId of snapshot.danglingCancels) {
|
|
92
|
+
if (effectAttemptedSet.has(activityId)) {
|
|
93
|
+
const result = await recoverCancelWithReconcile(ctx, snapshot, activityId, now());
|
|
94
|
+
if (result.kind === 'outcome')
|
|
95
|
+
cancelRecoveryOutcomes.push(result.outcome);
|
|
96
|
+
else if (result.kind === 'transient')
|
|
97
|
+
transientFailures.push(result.failure);
|
|
98
|
+
// 'skipped' = missing activity; ignore.
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const cancellation = await recoverCancel(ctx, snapshot, activityId);
|
|
102
|
+
if (cancellation)
|
|
103
|
+
cancelRecoveryOutcomes.push(cancellation);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Activities the cancel branch ALREADY terminated (succeeded or escalated
|
|
107
|
+
// to failed) — distinct from activities the cancel branch left dangling
|
|
108
|
+
// because reconcile reported transient.
|
|
109
|
+
const cancelTerminated = new Set(cancelRecoveryOutcomes.map((o) => o.activityId));
|
|
110
|
+
const reconcileOutcomes = [];
|
|
111
|
+
for (const activityId of snapshot.danglingEffectAttempted) {
|
|
112
|
+
if (cancelTerminated.has(activityId))
|
|
113
|
+
continue; // already handled by cancel branch
|
|
114
|
+
// Skip activities that the cancel branch tried but got transient on:
|
|
115
|
+
// we already recorded the transient failure there; running another
|
|
116
|
+
// reconcileOne for the same idempotencyKey would double-write.
|
|
117
|
+
if (snapshot.danglingCancels.includes(activityId)) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const result = await reconcileOne(ctx, snapshot, activityId, now());
|
|
121
|
+
if (result.kind === 'outcome')
|
|
122
|
+
reconcileOutcomes.push(result.outcome);
|
|
123
|
+
else if (result.kind === 'transient')
|
|
124
|
+
transientFailures.push(result.failure);
|
|
125
|
+
}
|
|
126
|
+
const cancelled = new Set(snapshot.danglingCancels);
|
|
127
|
+
// Step 8: wait recovery — `waitResolved` / `waitDeadlineExceeded`
|
|
128
|
+
// landed but the activity terminal didn't. Materialize the terminal
|
|
129
|
+
// from the recorded resolution so the next replay sees a clean
|
|
130
|
+
// terminal state.
|
|
131
|
+
const waitRecoveryOutcomes = [];
|
|
132
|
+
for (const activityId of snapshot.danglingWaitResolutions) {
|
|
133
|
+
if (cancelled.has(activityId))
|
|
134
|
+
continue;
|
|
135
|
+
const recovery = await recoverWaitResolution(ctx, snapshot, activityId);
|
|
136
|
+
if (recovery)
|
|
137
|
+
waitRecoveryOutcomes.push(recovery);
|
|
138
|
+
}
|
|
139
|
+
// Worker-crashed path: dangling activity, no effectAttempted, no
|
|
140
|
+
// open wait, no recoverable wait resolution → activityFailed{WorkerCrashed, retryable}.
|
|
141
|
+
const workerCrashedOutcomes = [];
|
|
142
|
+
const reconciled = new Set(snapshot.danglingEffectAttempted);
|
|
143
|
+
const waitingActivities = new Set(snapshot.danglingWaits);
|
|
144
|
+
const waitRecovered = new Set(snapshot.danglingWaitResolutions);
|
|
145
|
+
for (const activityId of snapshot.danglingActivities) {
|
|
146
|
+
if (cancelled.has(activityId))
|
|
147
|
+
continue;
|
|
148
|
+
if (reconciled.has(activityId))
|
|
149
|
+
continue;
|
|
150
|
+
if (waitingActivities.has(activityId))
|
|
151
|
+
continue;
|
|
152
|
+
if (waitRecovered.has(activityId))
|
|
153
|
+
continue;
|
|
154
|
+
const activity = snapshot.activities.get(activityId);
|
|
155
|
+
if (!activity)
|
|
156
|
+
continue;
|
|
157
|
+
const latest = activity.attempts[activity.attempts.length - 1];
|
|
158
|
+
if (!latest)
|
|
159
|
+
continue;
|
|
160
|
+
const terminalEvent = (await ctx.log.append({
|
|
161
|
+
runId: ctx.runId,
|
|
162
|
+
type: 'activityFailed',
|
|
163
|
+
actor: 'system',
|
|
164
|
+
payload: {
|
|
165
|
+
activityId,
|
|
166
|
+
attemptId: latest.attemptId,
|
|
167
|
+
error: {
|
|
168
|
+
errorCode: 'WorkerCrashed',
|
|
169
|
+
errorClass: 'retryable',
|
|
170
|
+
errorMessage: 'Worker process exited before the activity reached a terminal state.',
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
}));
|
|
174
|
+
workerCrashedOutcomes.push({ activityId, attemptId: latest.attemptId, terminalEvent });
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
resumeStartedEvent,
|
|
178
|
+
snapshot,
|
|
179
|
+
reconcileOutcomes,
|
|
180
|
+
workerCrashedOutcomes,
|
|
181
|
+
transientFailures,
|
|
182
|
+
waitRecoveryOutcomes,
|
|
183
|
+
cancelRecoveryOutcomes,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// ─── Cancel recovery (Step 9) ──────────────────────────────────────────────
|
|
187
|
+
/**
|
|
188
|
+
* Plain cancel recovery for activities WITHOUT a dangling effectAttempted.
|
|
189
|
+
* Writes `activityCanceled` directly — no provider state to reconcile.
|
|
190
|
+
*/
|
|
191
|
+
async function recoverCancel(ctx, snapshot, activityId) {
|
|
192
|
+
const activity = snapshot.activities.get(activityId);
|
|
193
|
+
if (!activity)
|
|
194
|
+
return null;
|
|
195
|
+
const latest = activity.attempts[activity.attempts.length - 1];
|
|
196
|
+
if (!latest?.cancelRequest)
|
|
197
|
+
return null;
|
|
198
|
+
const cr = latest.cancelRequest;
|
|
199
|
+
const terminalEvent = (await ctx.log.append({
|
|
200
|
+
runId: ctx.runId,
|
|
201
|
+
type: 'activityCanceled',
|
|
202
|
+
actor: 'system',
|
|
203
|
+
payload: {
|
|
204
|
+
activityId,
|
|
205
|
+
attemptId: latest.attemptId,
|
|
206
|
+
cancelOriginEventId: cr.cancelOriginEventId,
|
|
207
|
+
},
|
|
208
|
+
}));
|
|
209
|
+
return {
|
|
210
|
+
activityId,
|
|
211
|
+
attemptId: latest.attemptId,
|
|
212
|
+
cancelOriginEventId: cr.cancelOriginEventId,
|
|
213
|
+
delivered: cr.delivered,
|
|
214
|
+
kind: 'cancelled',
|
|
215
|
+
terminalEvent,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
async function recoverCancelWithReconcile(ctx, snapshot, activityId, nowMs) {
|
|
219
|
+
const activity = snapshot.activities.get(activityId);
|
|
220
|
+
if (!activity)
|
|
221
|
+
return { kind: 'skipped' };
|
|
222
|
+
const latest = activity.attempts[activity.attempts.length - 1];
|
|
223
|
+
if (!latest?.cancelRequest || !latest.effectAttempted)
|
|
224
|
+
return { kind: 'skipped' };
|
|
225
|
+
const cr = latest.cancelRequest;
|
|
226
|
+
const evidence = await captureEvidence(ctx, snapshot, activityId, nowMs, {
|
|
227
|
+
cancelContext: {
|
|
228
|
+
cancelOriginEventId: cr.cancelOriginEventId,
|
|
229
|
+
reason: cr.reason,
|
|
230
|
+
requestedBy: cr.requestedBy,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
if (evidence.kind === 'skipped')
|
|
234
|
+
return { kind: 'skipped' };
|
|
235
|
+
if (evidence.kind === 'transient') {
|
|
236
|
+
return { kind: 'transient', failure: evidence.failure };
|
|
237
|
+
}
|
|
238
|
+
const ea = latest.effectAttempted;
|
|
239
|
+
// Decision → terminal mapping for cancel branch.
|
|
240
|
+
if (evidence.kind === 'manual') {
|
|
241
|
+
// Provider state unknown — escalate to activityFailed{manual}. We
|
|
242
|
+
// intentionally do NOT write activityCanceled here: it would
|
|
243
|
+
// misrepresent the cancel as a clean abort even though we can't
|
|
244
|
+
// verify whether the provider performed the side effect. The
|
|
245
|
+
// cancelOriginEventId is preserved in the reconcile evidence for
|
|
246
|
+
// forensics.
|
|
247
|
+
const terminalEvent = (await ctx.log.append({
|
|
248
|
+
runId: ctx.runId,
|
|
249
|
+
type: 'activityFailed',
|
|
250
|
+
actor: 'system',
|
|
251
|
+
payload: {
|
|
252
|
+
activityId,
|
|
253
|
+
attemptId: latest.attemptId,
|
|
254
|
+
error: {
|
|
255
|
+
errorCode: evidence.errorCode,
|
|
256
|
+
errorClass: 'manual',
|
|
257
|
+
errorMessage: `Cancel + reconcile: ${evidence.errorMessage} (cancelOriginEventId=${cr.cancelOriginEventId})`,
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
}));
|
|
261
|
+
return {
|
|
262
|
+
kind: 'outcome',
|
|
263
|
+
outcome: {
|
|
264
|
+
activityId,
|
|
265
|
+
attemptId: latest.attemptId,
|
|
266
|
+
cancelOriginEventId: cr.cancelOriginEventId,
|
|
267
|
+
delivered: cr.delivered,
|
|
268
|
+
kind: 'failed',
|
|
269
|
+
reconcileEvent: evidence.reconcileEvent ?? undefined,
|
|
270
|
+
reconcileDecision: 'manual',
|
|
271
|
+
terminalEvent,
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
// completedByIdempotentSubmit OR freshRetry: cancel wins as terminal
|
|
276
|
+
// reason. Evidence is preserved in the reconcileResult written by
|
|
277
|
+
// captureEvidence (or referenced via the prior reconcileResult eventId
|
|
278
|
+
// when recovered=true).
|
|
279
|
+
const terminalEvent = (await ctx.log.append({
|
|
280
|
+
runId: ctx.runId,
|
|
281
|
+
type: 'activityCanceled',
|
|
282
|
+
actor: 'system',
|
|
283
|
+
payload: {
|
|
284
|
+
activityId,
|
|
285
|
+
attemptId: latest.attemptId,
|
|
286
|
+
cancelOriginEventId: cr.cancelOriginEventId,
|
|
287
|
+
},
|
|
288
|
+
}));
|
|
289
|
+
void ea;
|
|
290
|
+
return {
|
|
291
|
+
kind: 'outcome',
|
|
292
|
+
outcome: {
|
|
293
|
+
activityId,
|
|
294
|
+
attemptId: latest.attemptId,
|
|
295
|
+
cancelOriginEventId: cr.cancelOriginEventId,
|
|
296
|
+
delivered: cr.delivered,
|
|
297
|
+
kind: 'cancelled',
|
|
298
|
+
reconcileEvent: evidence.reconcileEvent ?? undefined,
|
|
299
|
+
reconcileDecision: evidence.kind,
|
|
300
|
+
terminalEvent,
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
// ─── Wait recovery (Step 8) ────────────────────────────────────────────────
|
|
305
|
+
async function recoverWaitResolution(ctx, snapshot, activityId) {
|
|
306
|
+
const activity = snapshot.activities.get(activityId);
|
|
307
|
+
if (!activity)
|
|
308
|
+
return null;
|
|
309
|
+
const latest = activity.attempts[activity.attempts.length - 1];
|
|
310
|
+
if (!latest?.wait?.resolution)
|
|
311
|
+
return null;
|
|
312
|
+
const r = latest.wait.resolution;
|
|
313
|
+
if (r.kind === 'resolved') {
|
|
314
|
+
// approved | external → activitySucceeded.
|
|
315
|
+
// rejected → activityFailed { InputValidationFailed, userFault }.
|
|
316
|
+
if (r.resolution === 'rejected') {
|
|
317
|
+
const terminalEvent = (await ctx.log.append({
|
|
318
|
+
runId: ctx.runId,
|
|
319
|
+
type: 'activityFailed',
|
|
320
|
+
actor: 'system',
|
|
321
|
+
payload: {
|
|
322
|
+
activityId,
|
|
323
|
+
attemptId: latest.attemptId,
|
|
324
|
+
error: {
|
|
325
|
+
errorCode: 'InputValidationFailed',
|
|
326
|
+
errorClass: 'userFault',
|
|
327
|
+
errorMessage: `Recovered wait terminal: rejected by ${r.by}${r.comment ? `: ${r.comment}` : ''}`,
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
}));
|
|
331
|
+
return {
|
|
332
|
+
activityId,
|
|
333
|
+
attemptId: latest.attemptId,
|
|
334
|
+
kind: 'failed',
|
|
335
|
+
source: 'resolved',
|
|
336
|
+
terminalEvent,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
// approved | external
|
|
340
|
+
const externalRefs = {
|
|
341
|
+
resolution: r.resolution,
|
|
342
|
+
by: r.by,
|
|
343
|
+
...(r.comment ? { comment: r.comment } : {}),
|
|
344
|
+
};
|
|
345
|
+
const terminalEvent = await writeRecoverySucceeded(ctx, activityId, latest.attemptId, externalRefs);
|
|
346
|
+
return {
|
|
347
|
+
activityId,
|
|
348
|
+
attemptId: latest.attemptId,
|
|
349
|
+
kind: 'succeeded',
|
|
350
|
+
source: 'resolved',
|
|
351
|
+
terminalEvent,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
// deadlineExceeded
|
|
355
|
+
const policy = latest.wait.onTimeout ?? 'fail';
|
|
356
|
+
if (policy === 'success') {
|
|
357
|
+
const externalRefs = { defaultedToTimeout: true, deadlineAt: r.deadlineAt };
|
|
358
|
+
const terminalEvent = await writeRecoverySucceeded(ctx, activityId, latest.attemptId, externalRefs);
|
|
359
|
+
return {
|
|
360
|
+
activityId,
|
|
361
|
+
attemptId: latest.attemptId,
|
|
362
|
+
kind: 'succeeded',
|
|
363
|
+
source: 'deadlineExceeded',
|
|
364
|
+
terminalEvent,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const terminalEvent = (await ctx.log.append({
|
|
368
|
+
runId: ctx.runId,
|
|
369
|
+
type: 'activityFailed',
|
|
370
|
+
actor: 'system',
|
|
371
|
+
payload: {
|
|
372
|
+
activityId,
|
|
373
|
+
attemptId: latest.attemptId,
|
|
374
|
+
error: {
|
|
375
|
+
errorCode: 'WaitDeadlineExceeded',
|
|
376
|
+
errorClass: 'userFault',
|
|
377
|
+
errorMessage: `Recovered wait terminal: deadline (${r.deadlineAt}) exceeded at ${r.exceededAtMs}`,
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
}));
|
|
381
|
+
return {
|
|
382
|
+
activityId,
|
|
383
|
+
attemptId: latest.attemptId,
|
|
384
|
+
kind: 'failed',
|
|
385
|
+
source: 'deadlineExceeded',
|
|
386
|
+
terminalEvent,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
async function writeRecoverySucceeded(ctx, activityId, attemptId, externalRefs) {
|
|
390
|
+
const outputBuf = Buffer.from(JSON.stringify(externalRefs), 'utf-8');
|
|
391
|
+
const outputHash = await sha256Hex(outputBuf);
|
|
392
|
+
return (await ctx.log.append({
|
|
393
|
+
runId: ctx.runId,
|
|
394
|
+
type: 'activitySucceeded',
|
|
395
|
+
actor: 'system',
|
|
396
|
+
payload: {
|
|
397
|
+
activityId,
|
|
398
|
+
attemptId,
|
|
399
|
+
outputRef: {
|
|
400
|
+
outputHash: `sha256:${outputHash}`,
|
|
401
|
+
outputBytes: outputBuf.length,
|
|
402
|
+
outputSchemaVersion: 1,
|
|
403
|
+
contentType: 'application/json',
|
|
404
|
+
},
|
|
405
|
+
externalRefs,
|
|
406
|
+
},
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
async function reconcileOne(ctx, snapshot, activityId, nowMs) {
|
|
410
|
+
const activity = snapshot.activities.get(activityId);
|
|
411
|
+
if (!activity)
|
|
412
|
+
return { kind: 'skipped' };
|
|
413
|
+
const latest = activity.attempts[activity.attempts.length - 1];
|
|
414
|
+
if (!latest?.effectAttempted)
|
|
415
|
+
return { kind: 'skipped' };
|
|
416
|
+
const ea = latest.effectAttempted;
|
|
417
|
+
const evidence = await captureEvidence(ctx, snapshot, activityId, nowMs);
|
|
418
|
+
if (evidence.kind === 'skipped')
|
|
419
|
+
return { kind: 'skipped' };
|
|
420
|
+
if (evidence.kind === 'transient')
|
|
421
|
+
return { kind: 'transient', failure: evidence.failure };
|
|
422
|
+
return { kind: 'outcome', outcome: await writeRegularTerminal(ctx, latest.attemptId, activityId, ea, evidence) };
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Capture reconcile evidence WITHOUT writing the activity terminal.
|
|
426
|
+
* Writes `reconcileResult` (or reuses a prior one — F1 recovery) and
|
|
427
|
+
* returns the decision + auxiliary data the caller needs to write the
|
|
428
|
+
* appropriate terminal.
|
|
429
|
+
*
|
|
430
|
+
* Splitting evidence capture from terminal write lets the cancel path
|
|
431
|
+
* (`recoverCancelWithReconcile`) reuse the decision tree without
|
|
432
|
+
* accidentally fabricating activitySucceeded — codex Step 9 round 1
|
|
433
|
+
* finding 1.
|
|
434
|
+
*/
|
|
435
|
+
async function captureEvidence(ctx, snapshot, activityId, nowMs, options) {
|
|
436
|
+
const activity = snapshot.activities.get(activityId);
|
|
437
|
+
if (!activity)
|
|
438
|
+
return { kind: 'skipped' };
|
|
439
|
+
const latest = activity.attempts[activity.attempts.length - 1];
|
|
440
|
+
if (!latest?.effectAttempted)
|
|
441
|
+
return { kind: 'skipped' };
|
|
442
|
+
const ea = latest.effectAttempted;
|
|
443
|
+
const extra = controlExtra(options);
|
|
444
|
+
// F1: recovery path — if a previous resume already wrote a
|
|
445
|
+
// reconcileResult for this attempt but crashed before the terminal,
|
|
446
|
+
// resume the consequences instead of re-running the decision tree.
|
|
447
|
+
// Re-running risks a DIFFERENT decision (TTL crosses, provider state
|
|
448
|
+
// changes), so we honor the recorded choice.
|
|
449
|
+
if (latest.latestReconcileResult) {
|
|
450
|
+
return evidenceFromPriorReconcileResult(latest, ea);
|
|
451
|
+
}
|
|
452
|
+
const reconciler = ctx.reconcilers.get(ea.provider);
|
|
453
|
+
// Case A — unknown provider. No way to confirm; manual/UnknownProvider.
|
|
454
|
+
if (!reconciler) {
|
|
455
|
+
return await writeReconcileResultManual(ctx, activityId, ea, 'none', 'UnknownProviderError', `No reconciler registered for provider "${ea.provider}".`, { reason: 'no_reconciler', ...extra });
|
|
456
|
+
}
|
|
457
|
+
// Case B — TTL boundary. Use the recorded TTL from effectAttempted,
|
|
458
|
+
// not the live reconciler's value: the provider's TTL may have changed
|
|
459
|
+
// between the attempt and this resume, but the contract that was in
|
|
460
|
+
// force at attempt time is what matters.
|
|
461
|
+
const ttlExpired = nowMs - ea.attemptedAtMs > ea.idempotencyTtlMs;
|
|
462
|
+
if (ttlExpired) {
|
|
463
|
+
return await writeReconcileResultManual(ctx, activityId, ea, 'none', 'TtlExpired', `Provider TTL (${ea.idempotencyTtlMs}ms) elapsed before resume could reconcile.`, {
|
|
464
|
+
reason: 'ttl_expired',
|
|
465
|
+
attemptedAtMs: ea.attemptedAtMs,
|
|
466
|
+
nowMs,
|
|
467
|
+
idempotencyTtlMs: ea.idempotencyTtlMs,
|
|
468
|
+
...extra,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
// F2: materialize effect input via the caller's loader. Some
|
|
472
|
+
// reconcilers can work without it (schedule); others (Feishu) MUST
|
|
473
|
+
// have it.
|
|
474
|
+
let effectInput = undefined;
|
|
475
|
+
let inputLoadError = null;
|
|
476
|
+
if (ctx.loadEffectInput) {
|
|
477
|
+
try {
|
|
478
|
+
effectInput = await ctx.loadEffectInput(activityId, latest.attemptId);
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
inputLoadError = err instanceof Error ? err : new Error(String(err));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (reconciler.requiresEffectInput &&
|
|
485
|
+
(inputLoadError !== null || effectInput === undefined)) {
|
|
486
|
+
return await writeReconcileResultManual(ctx, activityId, ea, 'none', 'InputUnrecoverable', inputLoadError
|
|
487
|
+
? `Failed to load effect input for reconcile: ${inputLoadError.message}`
|
|
488
|
+
: `Reconciler "${ea.provider}" requires effect input, but ctx.loadEffectInput returned undefined / was not provided.`, { reason: 'input_unrecoverable', hadLoader: !!ctx.loadEffectInput, ...extra });
|
|
489
|
+
}
|
|
490
|
+
// F2.5: inputHash guard. When a sidecar was successfully loaded, the
|
|
491
|
+
// body we're about to hand the reconciler MUST canonicalize to the
|
|
492
|
+
// hash that was recorded on `effectAttempted`. Sidecar tampering,
|
|
493
|
+
// schema drift, or manual edits would otherwise silently produce a
|
|
494
|
+
// re-submit with a different body — Feishu would dedupe by uuid and
|
|
495
|
+
// return the original messageId, but our workflow audit trail would
|
|
496
|
+
// record the tampered input as "successful".
|
|
497
|
+
//
|
|
498
|
+
// Only enforced when the reconciler declares `canonicalInput` so the
|
|
499
|
+
// contract is opt-in per provider. For `requiresEffectInput=true`
|
|
500
|
+
// reconcilers without a `canonicalInput`, fail loud: this is a
|
|
501
|
+
// config error (a Feishu-flavored reconciler that can't canonicalize
|
|
502
|
+
// its own input).
|
|
503
|
+
if (effectInput !== undefined) {
|
|
504
|
+
if (reconciler.canonicalInput) {
|
|
505
|
+
const recomputed = computeInputHash(reconciler.canonicalInput(effectInput));
|
|
506
|
+
if (recomputed !== ea.inputHash) {
|
|
507
|
+
return await writeReconcileResultManual(ctx, activityId, ea, 'none', 'IdempotencyInputMismatch', `Reconciler "${ea.provider}" loaded effect input whose canonical hash (${recomputed}) does not match the recorded effectAttempted.inputHash (${ea.inputHash}). Sidecar tampered or schema drifted; not calling provider.`, {
|
|
508
|
+
reason: 'inputhash_mismatch',
|
|
509
|
+
recordedHash: ea.inputHash,
|
|
510
|
+
recomputedHash: recomputed,
|
|
511
|
+
source: 'hashGuard',
|
|
512
|
+
...extra,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
else if (reconciler.requiresEffectInput) {
|
|
517
|
+
return await writeReconcileResultManual(ctx, activityId, ea, 'none', 'IdempotencyInputMismatch', `Reconciler "${ea.provider}" declares requiresEffectInput=true but exposes no canonicalInput — cannot verify the loaded sidecar matches effectAttempted.inputHash. Not calling provider.`, { reason: 'no_canonicalInput', source: 'hashGuard', ...extra });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// Case C — readOnlyLookup available. Prefer it: pure read, no side
|
|
521
|
+
// effect risk. Schedule has it.
|
|
522
|
+
if (reconciler.readOnlyLookup) {
|
|
523
|
+
const lookup = await reconciler.readOnlyLookup(ea.idempotencyKey, effectInput);
|
|
524
|
+
if (lookup.found) {
|
|
525
|
+
return await writeReconcileResultCompleted(ctx, activityId, ea, 'readOnlyLookup', lookup.externalRefs, { ...(lookup.evidence ?? {}), ...extra });
|
|
526
|
+
}
|
|
527
|
+
return await writeReconcileResultFreshRetry(ctx, activityId, ea, 'readOnlyLookup', { ...(lookup.evidence ?? { found: false }), ...extra });
|
|
528
|
+
}
|
|
529
|
+
// Case D — idempotentSubmit only (Feishu).
|
|
530
|
+
if (reconciler.idempotentSubmit) {
|
|
531
|
+
const submit = await reconciler.idempotentSubmit(ea.idempotencyKey, effectInput);
|
|
532
|
+
if (submit.ok) {
|
|
533
|
+
return await writeReconcileResultCompleted(ctx, activityId, ea, 'idempotentSubmit', submit.externalRefs, { ...(submit.evidence ?? {}), ...extra });
|
|
534
|
+
}
|
|
535
|
+
// F3: retryable failures stay dangling — no reconcileResult is
|
|
536
|
+
// written here because the provider's state is in flux. The next
|
|
537
|
+
// resume cycle re-enters the decision tree from scratch.
|
|
538
|
+
if (submit.errorClass === 'retryable') {
|
|
539
|
+
return {
|
|
540
|
+
kind: 'transient',
|
|
541
|
+
failure: {
|
|
542
|
+
activityId,
|
|
543
|
+
attemptId: latest.attemptId,
|
|
544
|
+
provider: ea.provider,
|
|
545
|
+
idempotencyKey: ea.idempotencyKey,
|
|
546
|
+
errorCode: submit.errorCode,
|
|
547
|
+
errorClass: 'retryable',
|
|
548
|
+
errorMessage: submit.errorMessage,
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
return await writeReconcileResultManual(ctx, activityId, ea, 'idempotentSubmit', submit.errorCode, submit.errorMessage, { ...(submit.evidence ?? { errorClass: submit.errorClass }), ...extra });
|
|
553
|
+
}
|
|
554
|
+
// Case E — reconciler exists but exposes no capability. Manual.
|
|
555
|
+
return await writeReconcileResultManual(ctx, activityId, ea, 'none', 'UnknownProviderError', `Reconciler for "${ea.provider}" exposes neither readOnlyLookup nor idempotentSubmit.`, { reason: 'no_capability', ...extra });
|
|
556
|
+
}
|
|
557
|
+
function controlExtra(options) {
|
|
558
|
+
if (!options?.cancelContext)
|
|
559
|
+
return {};
|
|
560
|
+
const { cancelOriginEventId, reason, requestedBy } = options.cancelContext;
|
|
561
|
+
return {
|
|
562
|
+
cancelOriginEventId,
|
|
563
|
+
cancelReason: reason,
|
|
564
|
+
cancelRequestedBy: requestedBy,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Write the regular (non-cancel) activity terminal that corresponds to
|
|
569
|
+
* an EvidenceResult. Returns a ReconcileOutcome for the orchestrator.
|
|
570
|
+
*
|
|
571
|
+
* Caller must filter out `transient` and `skipped` before invoking.
|
|
572
|
+
*/
|
|
573
|
+
async function writeRegularTerminal(ctx, attemptId, activityId, ea, evidence) {
|
|
574
|
+
switch (evidence.kind) {
|
|
575
|
+
case 'completedByIdempotentSubmit': {
|
|
576
|
+
const outputBuf = Buffer.from(JSON.stringify(evidence.externalRefs), 'utf-8');
|
|
577
|
+
const outputHash = await sha256Hex(outputBuf);
|
|
578
|
+
const terminalEvent = (await ctx.log.append({
|
|
579
|
+
runId: ctx.runId,
|
|
580
|
+
type: 'activitySucceeded',
|
|
581
|
+
actor: 'system',
|
|
582
|
+
payload: {
|
|
583
|
+
activityId,
|
|
584
|
+
attemptId,
|
|
585
|
+
outputRef: {
|
|
586
|
+
outputHash: `sha256:${outputHash}`,
|
|
587
|
+
outputBytes: outputBuf.length,
|
|
588
|
+
outputSchemaVersion: 1,
|
|
589
|
+
contentType: 'application/json',
|
|
590
|
+
},
|
|
591
|
+
externalRefs: evidence.externalRefs,
|
|
592
|
+
},
|
|
593
|
+
}));
|
|
594
|
+
return {
|
|
595
|
+
activityId,
|
|
596
|
+
attemptId,
|
|
597
|
+
idempotencyKey: ea.idempotencyKey,
|
|
598
|
+
provider: ea.provider,
|
|
599
|
+
capability: evidence.capability,
|
|
600
|
+
decision: 'completedByIdempotentSubmit',
|
|
601
|
+
evidence: evidence.evidence,
|
|
602
|
+
terminalEvent,
|
|
603
|
+
reconcileEvent: evidence.reconcileEvent,
|
|
604
|
+
recovered: evidence.recovered,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
case 'freshRetry': {
|
|
608
|
+
return {
|
|
609
|
+
activityId,
|
|
610
|
+
attemptId,
|
|
611
|
+
idempotencyKey: ea.idempotencyKey,
|
|
612
|
+
provider: ea.provider,
|
|
613
|
+
capability: evidence.capability,
|
|
614
|
+
decision: 'freshRetry',
|
|
615
|
+
evidence: evidence.evidence,
|
|
616
|
+
terminalEvent: null,
|
|
617
|
+
reconcileEvent: evidence.reconcileEvent,
|
|
618
|
+
recovered: evidence.recovered,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
case 'manual': {
|
|
622
|
+
const terminalEvent = (await ctx.log.append({
|
|
623
|
+
runId: ctx.runId,
|
|
624
|
+
type: 'activityFailed',
|
|
625
|
+
actor: 'system',
|
|
626
|
+
payload: {
|
|
627
|
+
activityId,
|
|
628
|
+
attemptId,
|
|
629
|
+
error: {
|
|
630
|
+
errorCode: evidence.errorCode,
|
|
631
|
+
errorClass: 'manual',
|
|
632
|
+
errorMessage: evidence.errorMessage,
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
}));
|
|
636
|
+
return {
|
|
637
|
+
activityId,
|
|
638
|
+
attemptId,
|
|
639
|
+
idempotencyKey: ea.idempotencyKey,
|
|
640
|
+
provider: ea.provider,
|
|
641
|
+
capability: evidence.capability,
|
|
642
|
+
decision: 'manual',
|
|
643
|
+
evidence: evidence.evidence,
|
|
644
|
+
terminalEvent,
|
|
645
|
+
reconcileEvent: evidence.reconcileEvent,
|
|
646
|
+
recovered: evidence.recovered,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
// ─── F1 recovery: a prior reconcileResult exists, terminal does not ─────────
|
|
652
|
+
/**
|
|
653
|
+
* Re-shape a prior crashed reconcile cycle's reconcileResult into an
|
|
654
|
+
* EvidenceResult so the caller's terminal-write path matches what would
|
|
655
|
+
* have happened originally.
|
|
656
|
+
*
|
|
657
|
+
* Codex Step 7 round 2: corrupt prior decisions (replayed without
|
|
658
|
+
* terminal, or completedByIdempotentSubmit with missing externalRefs)
|
|
659
|
+
* escalate to `manual` with diagnostic evidence rather than fabricate
|
|
660
|
+
* a fake activitySucceeded.
|
|
661
|
+
*/
|
|
662
|
+
function evidenceFromPriorReconcileResult(latest, _ea) {
|
|
663
|
+
void _ea;
|
|
664
|
+
const rr = latest.latestReconcileResult;
|
|
665
|
+
switch (rr.decision) {
|
|
666
|
+
case 'completedByIdempotentSubmit': {
|
|
667
|
+
const candidate = rr.evidence.externalRefs;
|
|
668
|
+
if (candidate === undefined ||
|
|
669
|
+
candidate === null ||
|
|
670
|
+
typeof candidate !== 'object' ||
|
|
671
|
+
Array.isArray(candidate)) {
|
|
672
|
+
return {
|
|
673
|
+
kind: 'manual',
|
|
674
|
+
capability: rr.capability,
|
|
675
|
+
errorCode: 'CorruptLog',
|
|
676
|
+
errorMessage: 'Prior reconcileResult{decision=completedByIdempotentSubmit} is missing evidence.externalRefs (or it is not an object) — refusing to fabricate an activitySucceeded from empty refs.',
|
|
677
|
+
evidence: {
|
|
678
|
+
...rr.evidence,
|
|
679
|
+
corruptReason: 'missing_external_refs',
|
|
680
|
+
originalDecision: 'completedByIdempotentSubmit',
|
|
681
|
+
reconcileEventId: rr.eventId,
|
|
682
|
+
},
|
|
683
|
+
reconcileEvent: null,
|
|
684
|
+
recovered: true,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
return {
|
|
688
|
+
kind: 'completedByIdempotentSubmit',
|
|
689
|
+
capability: rr.capability,
|
|
690
|
+
externalRefs: candidate,
|
|
691
|
+
evidence: rr.evidence,
|
|
692
|
+
reconcileEvent: null,
|
|
693
|
+
recovered: true,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
case 'manual': {
|
|
697
|
+
const errorCode = rr.evidence.errorCode ?? 'UnknownProviderError';
|
|
698
|
+
return {
|
|
699
|
+
kind: 'manual',
|
|
700
|
+
capability: rr.capability,
|
|
701
|
+
errorCode,
|
|
702
|
+
errorMessage: `Recovered from prior crashed reconcile cycle (decision=manual, errorCode=${errorCode}).`,
|
|
703
|
+
evidence: rr.evidence,
|
|
704
|
+
reconcileEvent: null,
|
|
705
|
+
recovered: true,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
case 'freshRetry': {
|
|
709
|
+
return {
|
|
710
|
+
kind: 'freshRetry',
|
|
711
|
+
capability: rr.capability,
|
|
712
|
+
evidence: rr.evidence,
|
|
713
|
+
reconcileEvent: null,
|
|
714
|
+
recovered: true,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
case 'replayed': {
|
|
718
|
+
// Replayed means a terminal already existed when reconcileResult
|
|
719
|
+
// was written. If we landed here, that terminal got lost — log
|
|
720
|
+
// corruption. Surface as manual to flag the inconsistency.
|
|
721
|
+
return {
|
|
722
|
+
kind: 'manual',
|
|
723
|
+
capability: rr.capability,
|
|
724
|
+
errorCode: 'CorruptLog',
|
|
725
|
+
errorMessage: 'Prior reconcileResult decision=replayed but no terminal event present — log inconsistency.',
|
|
726
|
+
evidence: {
|
|
727
|
+
...rr.evidence,
|
|
728
|
+
originalDecision: 'replayed',
|
|
729
|
+
reconcileEventId: rr.eventId,
|
|
730
|
+
},
|
|
731
|
+
reconcileEvent: null,
|
|
732
|
+
recovered: true,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
// ─── reconcileResult writers (decision-shaped EvidenceResult builders) ──────
|
|
738
|
+
async function writeReconcileResultCompleted(ctx, activityId, ea, capability, externalRefs, evidence) {
|
|
739
|
+
const reconcileEvent = (await ctx.log.append({
|
|
740
|
+
runId: ctx.runId,
|
|
741
|
+
type: 'reconcileResult',
|
|
742
|
+
actor: 'system',
|
|
743
|
+
payload: {
|
|
744
|
+
activityId,
|
|
745
|
+
idempotencyKey: ea.idempotencyKey,
|
|
746
|
+
capability,
|
|
747
|
+
decision: 'completedByIdempotentSubmit',
|
|
748
|
+
evidence: { ...evidence, externalRefs },
|
|
749
|
+
},
|
|
750
|
+
}));
|
|
751
|
+
return {
|
|
752
|
+
kind: 'completedByIdempotentSubmit',
|
|
753
|
+
capability,
|
|
754
|
+
externalRefs,
|
|
755
|
+
evidence,
|
|
756
|
+
reconcileEvent,
|
|
757
|
+
recovered: false,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
async function writeReconcileResultFreshRetry(ctx, activityId, ea, capability, evidence) {
|
|
761
|
+
const reconcileEvent = (await ctx.log.append({
|
|
762
|
+
runId: ctx.runId,
|
|
763
|
+
type: 'reconcileResult',
|
|
764
|
+
actor: 'system',
|
|
765
|
+
payload: {
|
|
766
|
+
activityId,
|
|
767
|
+
idempotencyKey: ea.idempotencyKey,
|
|
768
|
+
capability,
|
|
769
|
+
decision: 'freshRetry',
|
|
770
|
+
evidence,
|
|
771
|
+
},
|
|
772
|
+
}));
|
|
773
|
+
return {
|
|
774
|
+
kind: 'freshRetry',
|
|
775
|
+
capability,
|
|
776
|
+
evidence,
|
|
777
|
+
reconcileEvent,
|
|
778
|
+
recovered: false,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
async function writeReconcileResultManual(ctx, activityId, ea, capability, errorCode, errorMessage, evidence) {
|
|
782
|
+
const reconcileEvent = (await ctx.log.append({
|
|
783
|
+
runId: ctx.runId,
|
|
784
|
+
type: 'reconcileResult',
|
|
785
|
+
actor: 'system',
|
|
786
|
+
payload: {
|
|
787
|
+
activityId,
|
|
788
|
+
idempotencyKey: ea.idempotencyKey,
|
|
789
|
+
capability,
|
|
790
|
+
decision: 'manual',
|
|
791
|
+
evidence: { ...evidence, errorCode },
|
|
792
|
+
},
|
|
793
|
+
}));
|
|
794
|
+
return {
|
|
795
|
+
kind: 'manual',
|
|
796
|
+
capability,
|
|
797
|
+
errorCode,
|
|
798
|
+
errorMessage,
|
|
799
|
+
evidence,
|
|
800
|
+
reconcileEvent,
|
|
801
|
+
recovered: false,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
async function sha256Hex(buf) {
|
|
805
|
+
const { createHash } = await import('node:crypto');
|
|
806
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
807
|
+
}
|
|
808
|
+
//# sourceMappingURL=resume.js.map
|