clearotron 0.5.0-beta.2 → 0.5.0-beta.3
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/.env.example +7 -0
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +1 -0
- package/docs/architecture/05-config-governance.md +1 -1
- package/driver/CHANGELOG.md +11 -0
- package/driver/band-shape.mjs +4 -4
- package/driver/contract-vocabulary.mjs +3 -3
- package/driver/coverage-form-io.mjs +3 -1
- package/driver/coverage-form.mjs +21 -2
- package/driver/coverage-ledger.mjs +7 -1
- package/driver/declination-call.mjs +33 -16
- package/driver/degraded-parts.mjs +6 -0
- package/driver/engine/mcp/clarivate-server.mjs +2 -0
- package/driver/engine/mcp/corsearch-server.mjs +2 -0
- package/driver/engine/mcp/declination-server.mjs +4 -5
- package/driver/engine/mcp/euipo-server.mjs +2 -0
- package/driver/engine/mcp/free-tier-server.mjs +2 -0
- package/driver/engine/mcp/gather-config.mjs +15 -0
- package/driver/engine/mcp/held-answers.mjs +11 -3
- package/driver/engine/mcp/signa-server.mjs +2 -0
- package/driver/engine/mcp/supplemental.mjs +5 -2
- package/driver/engine/mcp/uspto-local-server.mjs +2 -0
- package/driver/envelope-settle.mjs +13 -3
- package/driver/gateway.mjs +31 -10
- package/driver/package.json +1 -1
- package/driver/parts-not-completed.mjs +52 -11
- package/driver/pipeline-knockout.mjs +5 -4
- package/driver/pipeline.mjs +436 -72
- package/driver/placement-decisions.mjs +4 -4
- package/driver/placement-form-io.mjs +21 -1
- package/driver/placement-form.mjs +25 -15
- package/driver/placement-union.mjs +53 -8
- package/driver/publish/index.mjs +7 -3
- package/driver/runner.mjs +4 -4
- package/driver/skills/clearance-register/digest.md +1 -1
- package/driver/skills/clearance-search/phase2-execution.md +3 -3
- package/driver/skills/clearance-search/synthesis-rules.md +3 -3
- package/driver/skills/placement-inquiry/SKILL.md +3 -3
- package/driver/stages.mjs +5 -5
- package/driver/suite-census.json +84 -12
- package/driver/verify.mjs +6 -6
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/_shared/README.md +1 -0
- package/providers/_shared/enumerate.mjs +4 -3
- package/providers/_shared/execute-plan.mjs +24 -7
- package/providers/_shared/send-until.mjs +25 -0
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/band-shape-probe.mjs +1 -1
- package/scripts/e2e-first-time.mjs +2 -1
- package/shared/names-in-force.mjs +1 -0
- package/shared/slices-taken-out.mjs +65 -0
- package/skills/clearotron-account/SKILL.md +1 -1
- package/skills/clearotron-ops/SKILL.md +1 -1
package/driver/gateway.mjs
CHANGED
|
@@ -224,7 +224,8 @@ export function toolWrittenArtifact(p) {
|
|
|
224
224
|
?? TOOL_WRITTEN_PATTERNS.find((r) => r.re.test(base))
|
|
225
225
|
?? null;
|
|
226
226
|
}
|
|
227
|
-
import { buildGatherMcpConfig, allowedToolsFor, toolGroupsForStage, recordAxisFor, seatWritesForGroups } from "./engine/mcp/gather-config.mjs";
|
|
227
|
+
import { buildGatherMcpConfig, allowedToolsFor, toolGroupsForStage, recordAxisFor, seatWritesForGroups, withSendUntil } from "./engine/mcp/gather-config.mjs";
|
|
228
|
+
import { sendReserveMs } from "../providers/_shared/send-until.mjs"; // each turn's plan calls stop sending ahead of its deadline
|
|
228
229
|
import { everyToolCallRefused } from "./engine/tool-refusal.mjs"; // one definition, shared with the engine probe
|
|
229
230
|
export { everyToolCallRefused };
|
|
230
231
|
// The profiles STORE root, for the write boundary only — read from the module that owns it, never
|
|
@@ -297,11 +298,11 @@ export function isEmbeddedFallback(json, stderr = "") {
|
|
|
297
298
|
// nonzero exit (the v14 delivery failure, previously mislabelled `nonzero_exit_1`); (3) any nonzero
|
|
298
299
|
// exit whose wall already reached the configured budget. Distinguished from transient errors so the
|
|
299
300
|
// retry policy can give a slow stage ONE longer shot instead of 3 identical doomed attempts.
|
|
300
|
-
export
|
|
301
|
-
|
|
302
|
-
if (
|
|
303
|
-
if (
|
|
304
|
-
return
|
|
301
|
+
export const isTimeout = (facts) => timeoutCause(facts) !== null;
|
|
302
|
+
export function timeoutCause({ killed, code, wall, stderr = "", timeoutSec }) { // which signal fired, or null
|
|
303
|
+
if (killed) return "killed";
|
|
304
|
+
if (/EmbeddedAttemptSessionTakeoverError|request timed out|\bETIMEDOUT\b/i.test(stderr)) return "stderr";
|
|
305
|
+
return code !== 0 && wall >= timeoutSec ? "wall" : null;
|
|
305
306
|
}
|
|
306
307
|
|
|
307
308
|
// A LANE WEDGE: a timeout whose turn moved ZERO tokens — the gateway never admitted it to a command lane (the
|
|
@@ -942,6 +943,9 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
942
943
|
}
|
|
943
944
|
}
|
|
944
945
|
}
|
|
946
|
+
// EACH TURN'S OWN COPY of the stage's tool config, carrying that turn's time to stop sending: ahead of the turn's
|
|
947
|
+
// deadline by the reserve, so a plan call returns what it holds while the turn can still write it (send-until.mjs).
|
|
948
|
+
const turnConfig = (sec) => withSendUntil(gatherMcpConfig, Date.now() + sec * 1000 - sendReserveMs(sec));
|
|
945
949
|
let lastFail;
|
|
946
950
|
// — WHAT ACTUALLY RAN, carried out to the caller. The attempt rows have held the truth all along
|
|
947
951
|
// (`modelActual` from the wire, `modelUsed` from the engine's own resolver); the ladder simply never
|
|
@@ -1125,13 +1129,14 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1125
1129
|
// the snapshot, which only widens the window by their own time: an earlier attempt's refusals all
|
|
1126
1130
|
// precede its own settle, so they stay outside it.
|
|
1127
1131
|
const dispatchedAt = Date.now();
|
|
1132
|
+
const mcpConfig = turnConfig(effTimeout); // this turn's copy, with its time to stop sending
|
|
1128
1133
|
for (const line of describeMethodologyDrift(witnessStageMethodology(runDir, name, effMessage, engineResolveSkill)))
|
|
1129
1134
|
note(`[${name}] ${line}`);
|
|
1130
1135
|
// — the frozen judged-by set, hashed into THIS PROCESS'S MEMORY before the seat runs. Never
|
|
1131
1136
|
// written to disk before the comparison, so the thing being watched cannot reach it. See
|
|
1132
1137
|
// run-integrity.mjs for why it is not a manifest file and why the append-only journals are excluded.
|
|
1133
1138
|
const integrityBefore = frozenSnapshot(runDir);
|
|
1134
|
-
const turn = await engine.runTurn({ agent, sessionKey: key, message: effMessage, grantDispatch: base, model, thinking, timeoutSec: effTimeout, resumeRef: warm ? lastSessionRef : undefined, codexHome: stageCodexHome, mcpConfig
|
|
1139
|
+
const turn = await engine.runTurn({ agent, sessionKey: key, message: effMessage, grantDispatch: base, model, thinking, timeoutSec: effTimeout, resumeRef: warm ? lastSessionRef : undefined, codexHome: stageCodexHome, mcpConfig, allowedTools: gatherAllowedTools, seatWrites: gatherSeatWrites, skillsDir: engineSkillsDir, skillsGrantRoots: engineSkillsGrantRoots, profilesDir: profilesStoreDir, resolveSkill: engineResolveSkill, runDir, stallSec,
|
|
1135
1140
|
progressFiles: files, // the no-progress watchdog's artifact-advance signal (anthropic-agent; other adapters ignore it)
|
|
1136
1141
|
// The raw stream of this session, beside its dispatch record (engine/session-record.mjs). The engines
|
|
1137
1142
|
// keep it while the disk has room and say on the tuple's `stream` what landed.
|
|
@@ -1282,7 +1287,10 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1282
1287
|
let fail = null;
|
|
1283
1288
|
let stampedQuantity = null; //: a validator's own exact count, when it stamped one (see below)
|
|
1284
1289
|
let failingFile = null; //: the artifact the validator rejected — the repair's only target
|
|
1285
|
-
|
|
1290
|
+
// A TURN THAT COMPLETED IS JUDGED ON WHAT IT WROTE, whatever its stderr says about a request inside it
|
|
1291
|
+
// (attemptTimedOut). Which signal fired is kept on the row either way.
|
|
1292
|
+
const timedOutBy = timeoutCause({ killed, code, wall, stderr, timeoutSec: effTimeout });
|
|
1293
|
+
if (attemptTimedOut({ cause: timedOutBy, code, json })) fail = "timeout";
|
|
1286
1294
|
else if (isEmbeddedFallback(json, stderr)) fail = "embedded_fallback";
|
|
1287
1295
|
else if (code !== 0) fail = `nonzero_exit_${code}`;
|
|
1288
1296
|
else if (!json) fail = "unparseable_json";
|
|
@@ -1354,7 +1362,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1354
1362
|
// under the run dir to win the grant back, and warmPatchMessage renders the target through rel();
|
|
1355
1363
|
// a relative path there would silently strip the write root off the one turn that needs it most.
|
|
1356
1364
|
const rt = await engine.runTurn({ agent, sessionKey: key, message: repairMessage, model, thinking,
|
|
1357
|
-
timeoutSec: effTimeout, resumeRef: repairRef, codexHome: stageCodexHome, mcpConfig:
|
|
1365
|
+
timeoutSec: effTimeout, resumeRef: repairRef, codexHome: stageCodexHome, mcpConfig: turnConfig(effTimeout), allowedTools: gatherAllowedTools,
|
|
1358
1366
|
skillsDir: engineSkillsDir, skillsGrantRoots: engineSkillsGrantRoots, profilesDir: profilesStoreDir, resolveSkill: engineResolveSkill, runDir, stallSec,
|
|
1359
1367
|
progressFiles: files, streamFile: streamFilePath(runDir, name, attempt, formRepairsUsed + 1) });
|
|
1360
1368
|
repairRef = rt.sessionRef ?? repairRef;
|
|
@@ -1819,6 +1827,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1819
1827
|
// `followup` matters too: a followup success PATCHES the prior session's output, it never
|
|
1820
1828
|
// supersedes a taint the way a fresh full pass does.
|
|
1821
1829
|
killed: killed || undefined,
|
|
1830
|
+
timeoutCause: timedOutBy ?? undefined, // — which of the three timeout signals fired: killed, stderr or wall
|
|
1822
1831
|
signals: turn.signals ?? undefined,
|
|
1823
1832
|
//: how long every declared artifact had been untouched when the turn settled. Present on any
|
|
1824
1833
|
// hard-wall kill the wall rescue looked at — INCLUDING the ones it refused, so "considered and
|
|
@@ -1899,7 +1908,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
|
|
|
1899
1908
|
cliVersion: cli.version, cliVersionProbe: cli.probe, ...(cli.why ? { cliVersionWhy: cli.why } : {}), cliSource: cli.source,
|
|
1900
1909
|
cliFloor: cli.floor ?? null, cliBelowFloor: cli.belowFloor ?? null,
|
|
1901
1910
|
wrote, warm: warm || undefined, warmEscalated: attempt === warmEscalatedAt || undefined,
|
|
1902
|
-
rescued: rescued ?? undefined, killed: killed || undefined,
|
|
1911
|
+
rescued: rescued ?? undefined, killed: killed || undefined, timeoutCause: timedOutBy ?? undefined,
|
|
1903
1912
|
quiescentMs: Number.isFinite(quiescentMs) ? Math.round(quiescentMs) : undefined, // — see the per-stage row
|
|
1904
1913
|
rescueRefused: rescueRefused ?? undefined, // — the cause, when the rescue looked and refused
|
|
1905
1914
|
rescueRefusedFile: rescueRefusedFile ?? undefined, rescueRefusedReason: rescueRefusedReason ?? undefined,
|
|
@@ -3735,3 +3744,15 @@ function toolCallCounts(turn) {
|
|
|
3735
3744
|
return { toolCallsRefused: n(turn?.toolCallsRefused), commandToolCalls: n(turn?.commandToolCalls), mcpToolCalls: n(turn?.mcpToolCalls),
|
|
3736
3745
|
toolCallsErroredByName: errored && typeof errored === "object" && !Array.isArray(errored) ? { ...errored } : null };
|
|
3737
3746
|
}
|
|
3747
|
+
|
|
3748
|
+
/**
|
|
3749
|
+
* IS THIS ATTEMPT A TIMEOUT? A kill and a run past the limit with a non-zero exit always are. A timed-out request named
|
|
3750
|
+
* in stderr is one only when the turn did not complete: a turn that exited 0 with its engine's status ok outlived the
|
|
3751
|
+
* request, which was a call inside it (a web lookup, a tool), and is judged on what it wrote like any finished turn.
|
|
3752
|
+
* Recorded as a timeout, it was run again whole: on one test run a writer finished in 14.7 minutes of its 43, exited 0
|
|
3753
|
+
* with its output written, and paid a second attempt of 20.3 minutes. `cause` is `timeoutCause`'s answer. PURE.
|
|
3754
|
+
*/
|
|
3755
|
+
export function attemptTimedOut({ cause, code, json }) {
|
|
3756
|
+
if (!cause) return false;
|
|
3757
|
+
return !(cause === "stderr" && code === 0 && json?.status === "ok");
|
|
3758
|
+
}
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.5.0-beta.
|
|
5
|
+
"version": "0.5.0-beta.3",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
|
|
8
8
|
"engines": {
|
|
@@ -17,35 +17,40 @@
|
|
|
17
17
|
//
|
|
18
18
|
// ONCE PER PART. The catch never writes a part whose record already exists: a failure on the pass that delivers
|
|
19
19
|
// without it ends the run as it always did, with the stop notice, and the record is removed then, so a later resume
|
|
20
|
-
// tries the part again.
|
|
20
|
+
// tries the part again. The searches are the one part whose record grows: each failure names the slices it could not
|
|
21
|
+
// prove ran, and a failure that names a slice the record does not hold yet adds it; one that names none new ends the
|
|
22
|
+
// run, so the run still ends.
|
|
21
23
|
//
|
|
22
|
-
// A pure leaf: node:fs, node:path
|
|
24
|
+
// A pure leaf: node:fs, node:path, shared/driver-dir.mjs and shared/slices-taken-out.mjs (node:fs and the same) only.
|
|
23
25
|
import { readFileSync, writeFileSync, renameSync, rmSync, readdirSync } from "node:fs";
|
|
24
26
|
import { join } from "node:path";
|
|
25
27
|
import { driverDir, ensureDriverDir } from "../shared/driver-dir.mjs";
|
|
28
|
+
// Where the record sits is stated with the band readers outside the driver, which read the searches' record too.
|
|
29
|
+
import { NOT_COMPLETED_DIR, notCompletedPath } from "../shared/slices-taken-out.mjs";
|
|
26
30
|
|
|
27
|
-
export
|
|
31
|
+
export { NOT_COMPLETED_DIR, notCompletedPath };
|
|
28
32
|
|
|
29
33
|
// The covered parts. `assessment` is the run's overall assessment: placement, the register write-up and synthesis, and
|
|
30
34
|
// everything built from them. `review` is the first review of the assessment, whose verdict the sign-off is. `overview`
|
|
31
35
|
// is the report overview, the conclusion under the rating. `delivery` is the
|
|
32
36
|
// changed-material check before publishing: it never delivers past staleness, and its record buys the report one more
|
|
33
|
-
// assembly from the current material instead of the stop notice.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
37
|
+
// assembly from the current material instead of the stop notice. `searches` is the register's searches: the slices a
|
|
38
|
+
// check could not prove ran, each with its axis, its qid and the reason it is open, taken out of the band and
|
|
39
|
+
// delivered as open rows; the axes whose pass was killed at its time limit, read as the last pass left them and
|
|
40
|
+
// disclosed, their clean claims open (`disclosed_axes`); and the units that wrote no result once their attempts were
|
|
41
|
+
// spent, delivered as their bands stand and not sent again (`unfinished_units`).
|
|
42
|
+
export const NOT_COMPLETED_PARTS = Object.freeze(["assessment", "review", "overview", "delivery", "searches"]);
|
|
40
43
|
|
|
41
44
|
/**
|
|
42
45
|
* Record that `part` could not be completed. `stage` is the stage that failed, `reason` its cause for the run's record
|
|
43
46
|
* (never a reader's cell), `sig` and `terminalKind` what the recovery decision said. Written through a temporary file
|
|
44
47
|
* and a rename, so a reader never sees half a record. Returns the record.
|
|
45
48
|
*/
|
|
46
|
-
export function writePartNotCompleted(runDir, part, { stage = null, reason = null, sig = null, terminalKind = null } = {}) {
|
|
49
|
+
export function writePartNotCompleted(runDir, part, { stage = null, reason = null, sig = null, terminalKind = null, slices = null, disclosedAxes = null, unfinishedUnits = null } = {}) {
|
|
47
50
|
if (!NOT_COMPLETED_PARTS.includes(part)) throw new Error(`parts-not-completed: "${part}" is not a covered part`);
|
|
48
51
|
const record = { part, stage, reason: reason == null ? null : String(reason).slice(0, 600), sig, terminalKind,
|
|
52
|
+
...(Array.isArray(slices) ? { slices } : {}), ...(Array.isArray(disclosedAxes) && disclosedAxes.length ? { disclosed_axes: disclosedAxes } : {}),
|
|
53
|
+
...(Array.isArray(unfinishedUnits) && unfinishedUnits.length ? { unfinished_units: unfinishedUnits } : {}),
|
|
49
54
|
ts: new Date().toISOString() };
|
|
50
55
|
const path = notCompletedPath(runDir, part);
|
|
51
56
|
ensureDriverDir(runDir, NOT_COMPLETED_DIR);
|
|
@@ -76,6 +81,42 @@ export function partsNotCompleted(runDir) {
|
|
|
76
81
|
return NOT_COMPLETED_PARTS.filter((p) => names.includes(`${p}.json`)).map((p) => readPartNotCompleted(runDir, p));
|
|
77
82
|
}
|
|
78
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The slices of `failed` that `record` does not hold yet, each `{axis, qid, reason}`, or `{axis, query, reason}` for a
|
|
86
|
+
* slice with no qid, which its query names: what a failure of the searches adds to their record. [] when it adds
|
|
87
|
+
* none. PURE.
|
|
88
|
+
*/
|
|
89
|
+
export function slicesNotYetRecorded(record, failed) {
|
|
90
|
+
const keyOf = (s) => `${s?.axis}\u0000${s?.qid ? `qid:${s.qid}` : `query:${s?.query}`}`;
|
|
91
|
+
const held = new Set((Array.isArray(record?.slices) ? record.slices : []).map(keyOf));
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const s of Array.isArray(failed) ? failed : []) {
|
|
94
|
+
if (!s?.axis || (!s?.qid && !s?.query) || held.has(keyOf(s))) continue;
|
|
95
|
+
held.add(keyOf(s));
|
|
96
|
+
out.push(s.qid ? { axis: String(s.axis), qid: String(s.qid), reason: String(s.reason ?? "") }
|
|
97
|
+
: { axis: String(s.axis), query: String(s.query), reason: String(s.reason ?? "") });
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The axes of `failed` that `record` does not list as disclosed yet: a pass on each was killed at its time limit and its
|
|
104
|
+
* recovery is spent, so the delivering pass reads its band as it stands and discloses it. [] when it adds none. PURE.
|
|
105
|
+
*/
|
|
106
|
+
export function axesNotYetDisclosed(record, failed) {
|
|
107
|
+
const held = new Set(Array.isArray(record?.disclosed_axes) ? record.disclosed_axes : []);
|
|
108
|
+
return [...new Set((Array.isArray(failed) ? failed : []).map((a) => String(a ?? "").trim()).filter((a) => a && !held.has(a)))];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The units of `failed` that `record` does not list as unfinished yet: each wrote no result once its attempts were spent,
|
|
113
|
+
* so the delivering pass takes it as its band stands instead of sending it again. [] when it adds none. PURE.
|
|
114
|
+
*/
|
|
115
|
+
export function unitsNotYetRecorded(record, failed) {
|
|
116
|
+
const held = new Set(Array.isArray(record?.unfinished_units) ? record.unfinished_units : []);
|
|
117
|
+
return [...new Set((Array.isArray(failed) ? failed : []).map((a) => String(a ?? "").trim()).filter((a) => a && !held.has(a)))];
|
|
118
|
+
}
|
|
119
|
+
|
|
79
120
|
/** Remove one part's record, once that part has completed after all. */
|
|
80
121
|
export function clearPartNotCompleted(runDir, part) {
|
|
81
122
|
try { rmSync(notCompletedPath(runDir, part), { force: true }); } catch { /* none */ }
|
|
@@ -64,7 +64,7 @@ import { writeRunStatus, readRunStatus, rollupStatus, atomicWrite, identitySeed
|
|
|
64
64
|
import { batchMarkName } from "./mark-name.mjs";
|
|
65
65
|
import { runLog, note, outputMeta } from "./log.mjs";
|
|
66
66
|
import { defaultTerritoryState } from "./effective-scope.mjs"; // the stored-defaults reading — one producer, shared with the clearance lane
|
|
67
|
-
import {
|
|
67
|
+
import { whatsappRouting } from "./stages.mjs";
|
|
68
68
|
import { writeOutboxPacket } from "./outbox.mjs";
|
|
69
69
|
import { rollupTokens, stampTokenRollup } from "./tokens.mjs";
|
|
70
70
|
import { recordRunConsumption } from "./consumption-ledger.mjs";
|
|
@@ -1205,8 +1205,9 @@ export async function knockoutInner(ctx, job, opts = {}) {
|
|
|
1205
1205
|
// two existing. Where no number is held the packet now SAYS so in `whatsappToReason` rather than
|
|
1206
1206
|
// substituting the operator silently.
|
|
1207
1207
|
//
|
|
1208
|
-
// The failure notice below
|
|
1209
|
-
//
|
|
1208
|
+
// The failure notice below routes the same way (buildFailurePacket). It used to keep `AGENT_WHATSAPP[agent]` as an
|
|
1209
|
+
// operational page, but the courier relays it to the requester, who is owed it as they are owed a report, and the
|
|
1210
|
+
// operator keeps the page as the copy beside it.
|
|
1210
1211
|
...whatsappRouting(job, agent),
|
|
1211
1212
|
// ONE LINE, N LINKS. This said "Report: <one url>" and on a batch that url is now null, which would
|
|
1212
1213
|
// have read "Report: null" — the fail-visible shape doing its job, and still not a line to send. A
|
|
@@ -1350,7 +1351,7 @@ export async function knockoutInner(ctx, job, opts = {}) {
|
|
|
1350
1351
|
try {
|
|
1351
1352
|
const rich = buildFailurePacket({
|
|
1352
1353
|
runId, agent, job, failedStage, shortReason, terminalKind,
|
|
1353
|
-
reasonVerbatim: String(reason).slice(0, 1000),
|
|
1354
|
+
reasonVerbatim: String(reason).slice(0, 1000),
|
|
1354
1355
|
});
|
|
1355
1356
|
const packet = {
|
|
1356
1357
|
kind: "run-failed", ...rich,
|