@inflexa-ai/harness 0.9.0 → 0.10.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/agents/conversation-agent.js +3 -1
- package/dist/execution/run-synthesis.d.ts +7 -0
- package/dist/execution/run-synthesis.js +8 -2
- package/dist/schemas/validate-plan.js +10 -5
- package/dist/tools/research/inspect-run.js +10 -3
- package/dist/tools/workspace/read-file.d.ts +5 -0
- package/dist/tools/workspace/read-file.js +20 -2
- package/dist/workflows/execute-analysis.d.ts +14 -1
- package/dist/workflows/execute-analysis.js +105 -8
- package/dist/workspace/paths.d.ts +10 -0
- package/dist/workspace/paths.js +10 -0
- package/package.json +1 -1
|
@@ -32,7 +32,7 @@ import { createWorkingMemory } from "../memory/working-memory.js";
|
|
|
32
32
|
import { conversationPrompt } from "../prompts/conversation.js";
|
|
33
33
|
import { composeSystemPrompt } from "./system-prompt.js";
|
|
34
34
|
// Bio-lookup leaf tools (pure — no dependencies).
|
|
35
|
-
import { searchGeneTool, searchPathwayTool, lookupGoTermTool, searchInteractionsTool, chemblTool, pubchemTool, openTargetsTool, searchPharmgkbTool, searchFaersTool, searchClinicalTrialsTool, searchGeoDatasetsTool, createSearchDgidbTool, searchBgeeExpressionTool, getImpcKoProfileTool, checkSafetyPanelTool, } from "../tools/bio/index.js";
|
|
35
|
+
import { searchGeneTool, searchPathwayTool, lookupGoTermTool, searchInteractionsTool, chemblTool, pubchemTool, openTargetsTool, searchGwasCatalogTool, searchPharmgkbTool, searchFaersTool, searchClinicalTrialsTool, searchGeoDatasetsTool, createSearchDgidbTool, searchBgeeExpressionTool, getImpcKoProfileTool, checkSafetyPanelTool, } from "../tools/bio/index.js";
|
|
36
36
|
import { createNcbiTools, createChemDbTools } from "../tools/bio/keys.js";
|
|
37
37
|
// Dependency-bearing tool factories.
|
|
38
38
|
import { createGeneratePlanTool, createLiteratureReviewerTool, createInspectRunTool, createInspectDataProfileTool, createGenerateAnalogyReportTool, } from "../tools/research/index.js";
|
|
@@ -68,6 +68,8 @@ export function createConversationAgent(deps) {
|
|
|
68
68
|
pubchemTool,
|
|
69
69
|
// Translational medicine.
|
|
70
70
|
openTargetsTool,
|
|
71
|
+
// Genetic evidence — SNP-trait associations for target validation / MR support.
|
|
72
|
+
searchGwasCatalogTool,
|
|
71
73
|
searchPharmgkbTool,
|
|
72
74
|
searchFaersTool,
|
|
73
75
|
searchClinicalTrialsTool,
|
|
@@ -30,6 +30,13 @@ import type { ChatProvider } from "../providers/types.js";
|
|
|
30
30
|
import type { RunSession } from "../auth/types.js";
|
|
31
31
|
import { type Tool } from "../tools/define-tool.js";
|
|
32
32
|
import type { BioToolKeys } from "../tools/bio/keys.js";
|
|
33
|
+
/**
|
|
34
|
+
* The synthesizer's agent identity — the `source.agentId` on its loop events
|
|
35
|
+
* and the `agent_id` of the run-phase `synthesis` row `executeAnalysis` writes
|
|
36
|
+
* to `cortex_step_executions`. One definition so the ledger row and the stream
|
|
37
|
+
* events can never disagree about who did the work.
|
|
38
|
+
*/
|
|
39
|
+
export declare const SYNTHESIS_AGENT_ID = "run-synthesizer";
|
|
33
40
|
type SynthesizerOutcome = {
|
|
34
41
|
kind: "submitted";
|
|
35
42
|
synthesis: RunSynthesis;
|
|
@@ -39,7 +39,13 @@ import { defineTool } from "../tools/define-tool.js";
|
|
|
39
39
|
import { createReportBlockerToolFor } from "../tools/sandbox/report-blocker.js";
|
|
40
40
|
import { createLiteratureReviewerTool } from "../tools/research/literature-reviewer.js";
|
|
41
41
|
// ── Agent identity / budgets ────────────────────────────────────────
|
|
42
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The synthesizer's agent identity — the `source.agentId` on its loop events
|
|
44
|
+
* and the `agent_id` of the run-phase `synthesis` row `executeAnalysis` writes
|
|
45
|
+
* to `cortex_step_executions`. One definition so the ledger row and the stream
|
|
46
|
+
* events can never disagree about who did the work.
|
|
47
|
+
*/
|
|
48
|
+
export const SYNTHESIS_AGENT_ID = "run-synthesizer";
|
|
43
49
|
/**
|
|
44
50
|
* Budget for the synthesizer loop: read summaries + 1–3 reviewer delegations
|
|
45
51
|
* + draft + 1–2 submit/fix cycles + headroom.
|
|
@@ -349,7 +355,7 @@ export async function generateRunSynthesis(input) {
|
|
|
349
355
|
const blockerTool = buildBlockerTool(holder);
|
|
350
356
|
const tools = [validateTool, submitTool, blockerTool, reviewer];
|
|
351
357
|
const agent = {
|
|
352
|
-
id:
|
|
358
|
+
id: SYNTHESIS_AGENT_ID,
|
|
353
359
|
systemPrompt: composeSystemPrompt(synthesisAgentPrompt),
|
|
354
360
|
model: input.model,
|
|
355
361
|
tools,
|
|
@@ -13,14 +13,17 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { KNOWN_AGENT_IDS } from "../agents/sandbox-catalog.js";
|
|
15
15
|
import { CycleError, DependencyError, topoSortIntoWaves } from "../execution/topo-sort.js";
|
|
16
|
-
import { isSafeId, STEP_SUBDIRS } from "../workspace/paths.js";
|
|
16
|
+
import { isSafeId, STEP_SUBDIRS, SYNTHESIS_STEP_ID } from "../workspace/paths.js";
|
|
17
17
|
const KNOWN_AGENTS = new Set(KNOWN_AGENT_IDS);
|
|
18
18
|
/**
|
|
19
19
|
* Reserved step-id names. A step id equal to an artifact subdirectory name
|
|
20
20
|
* would make its directory (`runs/{runId}/{stepId}`) collide with the
|
|
21
|
-
* subdirectory convention agents expect inside a step
|
|
21
|
+
* subdirectory convention agents expect inside a step, and
|
|
22
|
+
* {@link SYNTHESIS_STEP_ID} is the run-phase ledger row `executeAnalysis`
|
|
23
|
+
* writes for run-level synthesis — a plan step with that id would collide with
|
|
24
|
+
* the row's `(run_id, step_id)` primary key (see the harness-workspace-tools spec).
|
|
22
25
|
*/
|
|
23
|
-
const RESERVED_STEP_IDS = new Set(STEP_SUBDIRS);
|
|
26
|
+
const RESERVED_STEP_IDS = new Set([...STEP_SUBDIRS, SYNTHESIS_STEP_ID]);
|
|
24
27
|
/** Derive a filesystem-safe output prefix from a step ID. */
|
|
25
28
|
export function deriveOutputPrefix(stepId) {
|
|
26
29
|
return stepId
|
|
@@ -97,11 +100,13 @@ export function validatePlan(plan, options) {
|
|
|
97
100
|
`${ceiling.maxMemoryGb} per step — reduce memoryGb or restructure the step`);
|
|
98
101
|
}
|
|
99
102
|
}
|
|
100
|
-
// 5. Reserved step-id names (collide with artifact subdirectories
|
|
103
|
+
// 5. Reserved step-id names (collide with artifact subdirectories or the
|
|
104
|
+
// run-phase synthesis ledger row)
|
|
101
105
|
for (const step of plan.steps) {
|
|
102
106
|
if (RESERVED_STEP_IDS.has(step.id.toLowerCase())) {
|
|
103
107
|
errors.push(`Step "${step.id}" uses a reserved name — step ids must not be one of: ` +
|
|
104
|
-
`${
|
|
108
|
+
`${[...RESERVED_STEP_IDS].join(", ")} (the artifact subdirectory names collide with the ` +
|
|
109
|
+
`step-directory convention; "${SYNTHESIS_STEP_ID}" is the run-level synthesis phase)`);
|
|
105
110
|
}
|
|
106
111
|
}
|
|
107
112
|
// 6. Step-id path safety. The id becomes the `runs/{runId}/{stepId}` directory
|
|
@@ -16,6 +16,7 @@ import { ok } from "neverthrow";
|
|
|
16
16
|
import { z } from "zod";
|
|
17
17
|
import { queryRun, queryRunsByAnalysis, queryStepsByRun } from "../../state/index.js";
|
|
18
18
|
import { scopeResource } from "../../auth/types.js";
|
|
19
|
+
import { SYNTHESIS_STEP_ID } from "../../workspace/paths.js";
|
|
19
20
|
import { unwrapOrThrow } from "../../lib/result.js";
|
|
20
21
|
import { defineTool } from "../define-tool.js";
|
|
21
22
|
function formatRun(r, verbose) {
|
|
@@ -73,9 +74,15 @@ export function createInspectRunTool(pool) {
|
|
|
73
74
|
agentId: s.agentId,
|
|
74
75
|
wave: s.wave,
|
|
75
76
|
status: s.status,
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
77
|
+
// No per-step `summaryPath` when the file cannot exist: a seeded row
|
|
78
|
+
// (`pending`/`skipped`) never produced output, and the reserved
|
|
79
|
+
// `synthesis` phase is not a sandbox step — it writes the run-level
|
|
80
|
+
// `synthesis.json`, surfaced as the run's `synthesisPath`, not a
|
|
81
|
+
// `{stepId}/output/summary.md`. Emitting the per-step path for it would
|
|
82
|
+
// point the agent at a file that never exists.
|
|
83
|
+
...(s.status === "pending" || s.status === "skipped" || s.stepId === SYNTHESIS_STEP_ID
|
|
84
|
+
? {}
|
|
85
|
+
: { summaryPath: `runs/${input.runId}/${s.stepId}/output/summary.md` }),
|
|
79
86
|
...(verbose
|
|
80
87
|
? {
|
|
81
88
|
durationMs: s.durationMs,
|
|
@@ -30,6 +30,11 @@ type ReadFileOutput = {
|
|
|
30
30
|
} | {
|
|
31
31
|
status: "out_of_scope";
|
|
32
32
|
path: string;
|
|
33
|
+
} | {
|
|
34
|
+
status: "binary";
|
|
35
|
+
path: string;
|
|
36
|
+
mode: "head" | "tail" | "full";
|
|
37
|
+
totalSize?: number;
|
|
33
38
|
};
|
|
34
39
|
export declare function createReadFileTool(fs: WorkspaceFilesystem, workingDir?: string): import("../define-tool.js").Tool<{
|
|
35
40
|
path: string;
|
|
@@ -13,6 +13,18 @@ import { defineTool } from "../define-tool.js";
|
|
|
13
13
|
import { unwrapOrThrow } from "../../lib/result.js";
|
|
14
14
|
/** Default cap — large enough for typical CSV/MD/JSON, small enough to keep tool results out of context-blowing territory. */
|
|
15
15
|
const DEFAULT_MAX_BYTES = 256 * 1024;
|
|
16
|
+
/**
|
|
17
|
+
* Treat returned content as binary when it contains a NUL byte — `grep`'s
|
|
18
|
+
* `looksBinary` heuristic, applied here to the whole returned window rather than
|
|
19
|
+
* a 1 KiB probe. These exact bytes are what would be UTF-8-decoded into the
|
|
20
|
+
* transcript, and a NUL both renders that decode as lossy garbage (useless to
|
|
21
|
+
* the model) and cannot be persisted into the JSONB message store — Postgres
|
|
22
|
+
* rejects U+0000 with 22P05, failing the whole turn's append. Reporting the
|
|
23
|
+
* read as `binary` keeps a NUL out of both.
|
|
24
|
+
*/
|
|
25
|
+
function looksBinary(content) {
|
|
26
|
+
return content.includes(0);
|
|
27
|
+
}
|
|
16
28
|
const ReadFileInputSchema = z.object({
|
|
17
29
|
path: z
|
|
18
30
|
.string()
|
|
@@ -45,8 +57,10 @@ export function createReadFileTool(fs, workingDir) {
|
|
|
45
57
|
"as UTF-8 text. Output is capped at " +
|
|
46
58
|
`${DEFAULT_MAX_BYTES} bytes; pass headLines or tailLines to read a ` +
|
|
47
59
|
"specific window of a large file (typical for bio CSV/TSV/log files). " +
|
|
48
|
-
"Oversize reads come back truncated with a marker.
|
|
49
|
-
"
|
|
60
|
+
"Oversize reads come back truncated with a marker. This tool is for " +
|
|
61
|
+
"text; a binary file (one holding NUL bytes, e.g. a .gz or image) comes " +
|
|
62
|
+
"back as a `binary` data variant rather than decoded garbage. Missing " +
|
|
63
|
+
"paths and paths outside the analysis tree return a data variant — not an error.",
|
|
50
64
|
inputSchema: ReadFileInputSchema,
|
|
51
65
|
execute: async ({ path, headLines, tailLines }, ctx) => {
|
|
52
66
|
if (headLines !== undefined && tailLines !== undefined) {
|
|
@@ -67,6 +81,8 @@ export function createReadFileTool(fs, workingDir) {
|
|
|
67
81
|
const mode = headLines !== undefined ? "head" : tailLines !== undefined ? "tail" : "full";
|
|
68
82
|
switch (result.kind) {
|
|
69
83
|
case "ok":
|
|
84
|
+
if (looksBinary(result.content))
|
|
85
|
+
return ok({ status: "binary", path, mode });
|
|
70
86
|
return ok({
|
|
71
87
|
status: "ok",
|
|
72
88
|
path,
|
|
@@ -74,6 +90,8 @@ export function createReadFileTool(fs, workingDir) {
|
|
|
74
90
|
content: result.content.toString("utf8"),
|
|
75
91
|
});
|
|
76
92
|
case "truncated":
|
|
93
|
+
if (looksBinary(result.content))
|
|
94
|
+
return ok({ status: "binary", path, mode, totalSize: result.totalSize });
|
|
77
95
|
return ok({
|
|
78
96
|
status: "truncated",
|
|
79
97
|
path,
|
|
@@ -63,7 +63,7 @@ import type { MachineBudget, ResourceSpec } from "../config/resource-limits.js";
|
|
|
63
63
|
import type { RunSession } from "../auth/types.js";
|
|
64
64
|
import type { RunAuthorizer } from "../execution/run-authorizer.js";
|
|
65
65
|
import type { Logger } from "../lib/logger.js";
|
|
66
|
-
import type { SynthesisStatus } from "../state/index.js";
|
|
66
|
+
import type { SynthesisStatus, UpdateStepExecutionInput } from "../state/index.js";
|
|
67
67
|
import type { AnalysisStep } from "../schemas/workflow-state.js";
|
|
68
68
|
import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
|
|
69
69
|
import type { BioToolKeys } from "../tools/bio/keys.js";
|
|
@@ -288,6 +288,19 @@ export declare function composeStepSeed(args: {
|
|
|
288
288
|
runId: string;
|
|
289
289
|
deps: ExecuteAnalysisDeps;
|
|
290
290
|
}): Promise<string>;
|
|
291
|
+
/**
|
|
292
|
+
* Project a classified synthesis outcome onto the run-phase `synthesis` ledger
|
|
293
|
+
* row's terminal update (see the run-synthesis-outcome spec). The blocker keeps
|
|
294
|
+
* its reason in `blockedReason` — an honest agent-declared blocker, not an
|
|
295
|
+
* error — and a failure carries the same reason string
|
|
296
|
+
* `persist-synthesis-outcome` writes to `cortex_runs.synthesis_reason`, so the
|
|
297
|
+
* two ledgers cannot disagree about why synthesis died. Exported for the
|
|
298
|
+
* projection tests (the `buildChildInput` pattern).
|
|
299
|
+
*/
|
|
300
|
+
export declare function synthesisRowUpdate(outcome: {
|
|
301
|
+
status: SynthesisStatus;
|
|
302
|
+
reason: string | null;
|
|
303
|
+
}, durationMs: number): UpdateStepExecutionInput;
|
|
291
304
|
/**
|
|
292
305
|
* Run-level literature-grounded synthesis (no sandbox). Returns the
|
|
293
306
|
* quick-display findings for the run-completed card; empty when synthesis is
|
|
@@ -58,17 +58,17 @@
|
|
|
58
58
|
* - WRITES NOTHING to the conversation thread (results are pull-only
|
|
59
59
|
* via `inspectRun`)
|
|
60
60
|
*/
|
|
61
|
-
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
61
|
+
import { DBOS, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
|
|
62
62
|
import { mkdir } from "node:fs/promises";
|
|
63
63
|
import { join } from "node:path";
|
|
64
64
|
import { forStep } from "../auth/types.js";
|
|
65
65
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
66
66
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
67
|
-
import { RunDedupCollisionError, countArtifactsForRun, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, } from "../state/index.js";
|
|
67
|
+
import { RunDedupCollisionError, countArtifactsForRun, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
|
|
68
68
|
import { isBudgetExceeded } from "../loop/budget-exceeded.js";
|
|
69
|
-
import { loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
|
|
69
|
+
import { SYNTHESIS_AGENT_ID, loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
|
|
70
70
|
import { MAX_UPSTREAM_ARTIFACTS, composeStepBriefing } from "../prompts/briefing.js";
|
|
71
|
-
import { runDir, runStepDir, stepSubdir, stepWritePrefix, toSandboxPath } from "../workspace/paths.js";
|
|
71
|
+
import { SYNTHESIS_STEP_ID, runDir, runStepDir, stepSubdir, stepWritePrefix, toSandboxPath } from "../workspace/paths.js";
|
|
72
72
|
import { isChatDataPart } from "../sandbox/sandbox-step-translate.js";
|
|
73
73
|
import { synthesizeRun } from "../app/synthesize-run.js";
|
|
74
74
|
import { computeTopologicalLevels, scheduleReady, validatePlanDag } from "./execute-analysis-scheduler.js";
|
|
@@ -222,6 +222,31 @@ async function collectUpstreamHandoffs(args) {
|
|
|
222
222
|
}
|
|
223
223
|
return handoffs;
|
|
224
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Project a classified synthesis outcome onto the run-phase `synthesis` ledger
|
|
227
|
+
* row's terminal update (see the run-synthesis-outcome spec). The blocker keeps
|
|
228
|
+
* its reason in `blockedReason` — an honest agent-declared blocker, not an
|
|
229
|
+
* error — and a failure carries the same reason string
|
|
230
|
+
* `persist-synthesis-outcome` writes to `cortex_runs.synthesis_reason`, so the
|
|
231
|
+
* two ledgers cannot disagree about why synthesis died. Exported for the
|
|
232
|
+
* projection tests (the `buildChildInput` pattern).
|
|
233
|
+
*/
|
|
234
|
+
export function synthesisRowUpdate(outcome, durationMs) {
|
|
235
|
+
switch (outcome.status) {
|
|
236
|
+
case "produced":
|
|
237
|
+
return { status: "completed", durationMs };
|
|
238
|
+
case "skipped_no_summaries":
|
|
239
|
+
return { status: "skipped", durationMs };
|
|
240
|
+
case "skipped_blocker":
|
|
241
|
+
return { status: "blocked", durationMs, blockedReason: outcome.reason };
|
|
242
|
+
case "failed":
|
|
243
|
+
return { status: "failed", durationMs, error: outcome.reason };
|
|
244
|
+
default: {
|
|
245
|
+
const _exhaustive = outcome.status;
|
|
246
|
+
throw new Error(`unhandled synthesis status: ${String(_exhaustive)}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
225
250
|
/**
|
|
226
251
|
* Run-level literature-grounded synthesis (no sandbox). Returns the
|
|
227
252
|
* quick-display findings for the run-completed card; empty when synthesis is
|
|
@@ -309,14 +334,32 @@ export async function runExecuteAnalysisBody(input, deps) {
|
|
|
309
334
|
// The seed's DO NOTHING conflict action makes this replay-safe; the agent
|
|
310
335
|
// fallback mirrors `buildChildInput` so seed and mark-running agree on the
|
|
311
336
|
// row a step's child later upserts.
|
|
337
|
+
//
|
|
338
|
+
// The run-phase `synthesis` row is seeded alongside the DAG — gated on
|
|
339
|
+
// enablement ONLY, because the other half of the run gate for actually
|
|
340
|
+
// executing synthesis (`final.completed.size > 0`) is unknowable until the
|
|
341
|
+
// scheduler returns. A seeded row whose gate later fails simply stays
|
|
342
|
+
// `pending` and the terminal sweep finalizes it to `skipped` — honest
|
|
343
|
+
// ("never ran, never will"), like any never-dispatched DAG step. Its wave
|
|
344
|
+
// sits after every DAG level so ledger-ordered readers render it last.
|
|
345
|
+
const synthesisEnabled = deps.synthesisEnabled ?? true;
|
|
346
|
+
const synthesisWave = Math.max(-1, ...levels.values()) + 1;
|
|
312
347
|
await DBOS.runStep(async () => {
|
|
313
|
-
|
|
348
|
+
const dagRows = input.steps.map((step) => ({
|
|
314
349
|
runId,
|
|
315
350
|
stepId: step.id,
|
|
316
351
|
analysisId: input.analysisId,
|
|
317
352
|
wave: levels.get(step.id) ?? 0,
|
|
318
353
|
agentId: input.agentByStepId[step.id] ?? "scientific-executor",
|
|
319
|
-
}))
|
|
354
|
+
}));
|
|
355
|
+
const synthesisRow = {
|
|
356
|
+
runId,
|
|
357
|
+
stepId: SYNTHESIS_STEP_ID,
|
|
358
|
+
analysisId: input.analysisId,
|
|
359
|
+
wave: synthesisWave,
|
|
360
|
+
agentId: SYNTHESIS_AGENT_ID,
|
|
361
|
+
};
|
|
362
|
+
unwrapOrThrow(await seedStepExecutions(deps.pool, synthesisEnabled ? [...dagRows, synthesisRow] : dagRows));
|
|
320
363
|
}, { name: "seed-step-executions" });
|
|
321
364
|
// One checkpointed clock read stamps the run's start; the terminal read in
|
|
322
365
|
// `collectAndComplete` subtracts it for the true run span. Checkpointed →
|
|
@@ -352,10 +395,37 @@ export async function runExecuteAnalysisBody(input, deps) {
|
|
|
352
395
|
let synthesisError = null;
|
|
353
396
|
let synthesisFindings = [];
|
|
354
397
|
// Null when synthesis never ran (disabled or no completed steps): the ledger
|
|
355
|
-
// columns stay NULL — "unknown", not a recorded skip
|
|
398
|
+
// columns stay NULL — "unknown", not a recorded skip — and the seeded
|
|
399
|
+
// `synthesis` row (if any) stays `pending` for the terminal sweep.
|
|
356
400
|
let synthesisOutcome = null;
|
|
357
|
-
const synthesisEnabled = deps.synthesisEnabled ?? true;
|
|
358
401
|
if (synthesisEnabled && final.completed.size > 0) {
|
|
402
|
+
// Bracketing clock reads for the row's duration_ms — checkpointed, so a
|
|
403
|
+
// recovered replay reuses the original instants (same rule as `startedAtMs`).
|
|
404
|
+
const synthStartedAtMs = await DBOS.now();
|
|
405
|
+
// Mark the seeded `synthesis` row running. The same upsert the DAG children
|
|
406
|
+
// use: it stamps started_at, and it CREATES the row outright when the seed
|
|
407
|
+
// never carried it (synthesisEnabled flipped true across a recovery
|
|
408
|
+
// redeploy) — the mark self-heals rather than trusting the seed's snapshot
|
|
409
|
+
// of the config. Log-don't-fail: a progress row must never fail an
|
|
410
|
+
// otherwise-healthy run. Cancellation still re-propagates — it is not a
|
|
411
|
+
// write failure, and swallowing it here would only defer it one operation.
|
|
412
|
+
try {
|
|
413
|
+
await DBOS.runStep(async () => {
|
|
414
|
+
unwrapOrThrow(await insertStepExecution(deps.pool, {
|
|
415
|
+
runId,
|
|
416
|
+
stepId: SYNTHESIS_STEP_ID,
|
|
417
|
+
analysisId: input.analysisId,
|
|
418
|
+
wave: synthesisWave,
|
|
419
|
+
agentId: SYNTHESIS_AGENT_ID,
|
|
420
|
+
childWorkflowId: null,
|
|
421
|
+
}));
|
|
422
|
+
}, { name: "mark-synthesis-running" });
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
if (err instanceof DBOSErrors.DBOSWorkflowCancelledError)
|
|
426
|
+
throw err;
|
|
427
|
+
logger.error("mark-synthesis-running failed", { runId, ...logger.errorFields(err) });
|
|
428
|
+
}
|
|
359
429
|
try {
|
|
360
430
|
const synthOut = await DBOS.runStep(() => synthesizeFindings({
|
|
361
431
|
analysisId: input.analysisId,
|
|
@@ -368,10 +438,37 @@ export async function runExecuteAnalysisBody(input, deps) {
|
|
|
368
438
|
synthesisOutcome = { status: synthOut.synthesisStatus, reason: synthOut.synthesisReason };
|
|
369
439
|
}
|
|
370
440
|
catch (err) {
|
|
441
|
+
// Cancellation is not a synthesis outcome: re-propagate unchanged (the
|
|
442
|
+
// sandbox-step rule), so a cancelled workflow is never recorded as a
|
|
443
|
+
// synthesis failure — neither on the row nor in cortex_runs. The row is
|
|
444
|
+
// deliberately left `running`: `collectAndComplete` never executes on
|
|
445
|
+
// this path either, so the run row is equally non-terminal and the pair
|
|
446
|
+
// stays consistent — the pre-existing cancelled-mid-flight wedge shape
|
|
447
|
+
// `inflexa run` already detects via its DBOS-status cross-check.
|
|
448
|
+
if (err instanceof DBOSErrors.DBOSWorkflowCancelledError)
|
|
449
|
+
throw err;
|
|
371
450
|
synthesisError = err;
|
|
372
451
|
synthesisOutcome = { status: "failed", reason: err instanceof Error ? err.message : String(err) };
|
|
373
452
|
logger.error("synthesizeFindings failed — failing the run", { runId, ...logger.errorFields(err) });
|
|
374
453
|
}
|
|
454
|
+
// Terminal stamp for the `synthesis` row — deliberately BEFORE
|
|
455
|
+
// `collectAndComplete` writes the run row's terminal status, so no reader
|
|
456
|
+
// can observe a terminal run beside a still-`running` synthesis row. Both
|
|
457
|
+
// branches above set `synthesisOutcome` (or threw), so it is non-null here
|
|
458
|
+
// and the compiler narrows it — no guard needed. Log-don't-fail, like the
|
|
459
|
+
// sibling finalisation writes.
|
|
460
|
+
const rowOutcome = synthesisOutcome;
|
|
461
|
+
try {
|
|
462
|
+
const synthEndedAtMs = await DBOS.now();
|
|
463
|
+
await DBOS.runStep(async () => {
|
|
464
|
+
unwrapOrThrow(await updateStepExecution(deps.pool, runId, SYNTHESIS_STEP_ID, synthesisRowUpdate(rowOutcome, synthEndedAtMs - synthStartedAtMs)));
|
|
465
|
+
}, { name: "persist-synthesis-step" });
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
if (err instanceof DBOSErrors.DBOSWorkflowCancelledError)
|
|
469
|
+
throw err;
|
|
470
|
+
logger.error("persist-synthesis-step failed", { runId, ...logger.errorFields(err) });
|
|
471
|
+
}
|
|
375
472
|
}
|
|
376
473
|
// (6) collectAndComplete — terminal block. Runs on EVERY path. When synthesis
|
|
377
474
|
// failed, force a failed terminal status so the charge/run-authorization close correctly
|
|
@@ -181,5 +181,15 @@ export declare function stepSubdir(stepBase: string, type: StepSubdirType): stri
|
|
|
181
181
|
export declare function reportDir(reportId: string): string;
|
|
182
182
|
/** All standard subdirectory types for a step. */
|
|
183
183
|
export declare const STEP_SUBDIRS: readonly StepSubdirType[];
|
|
184
|
+
/**
|
|
185
|
+
* Reserved id of the run-phase synthesis row in `cortex_step_executions` — the
|
|
186
|
+
* ledger row `executeAnalysis` writes for run-level synthesis so progress
|
|
187
|
+
* readers see the phase. Reserved here beside {@link STEP_SUBDIRS} because the
|
|
188
|
+
* reservation is a layout fact as much as a ledger one: a plan step with this
|
|
189
|
+
* id would collide with the row's primary key AND put its step directory
|
|
190
|
+
* `runs/{runId}/synthesis/` beside the run-level `runs/{runId}/synthesis.json`.
|
|
191
|
+
* Plan validation rejects it (see validate-plan).
|
|
192
|
+
*/
|
|
193
|
+
export declare const SYNTHESIS_STEP_ID = "synthesis";
|
|
184
194
|
/** Get all subdirectory paths for a step. */
|
|
185
195
|
export declare function allStepSubdirs(stepBase: string): string[];
|
package/dist/workspace/paths.js
CHANGED
|
@@ -213,6 +213,16 @@ export function reportDir(reportId) {
|
|
|
213
213
|
}
|
|
214
214
|
/** All standard subdirectory types for a step. */
|
|
215
215
|
export const STEP_SUBDIRS = ["scripts", "output", "figures", "logs", "notebooks"];
|
|
216
|
+
/**
|
|
217
|
+
* Reserved id of the run-phase synthesis row in `cortex_step_executions` — the
|
|
218
|
+
* ledger row `executeAnalysis` writes for run-level synthesis so progress
|
|
219
|
+
* readers see the phase. Reserved here beside {@link STEP_SUBDIRS} because the
|
|
220
|
+
* reservation is a layout fact as much as a ledger one: a plan step with this
|
|
221
|
+
* id would collide with the row's primary key AND put its step directory
|
|
222
|
+
* `runs/{runId}/synthesis/` beside the run-level `runs/{runId}/synthesis.json`.
|
|
223
|
+
* Plan validation rejects it (see validate-plan).
|
|
224
|
+
*/
|
|
225
|
+
export const SYNTHESIS_STEP_ID = "synthesis";
|
|
216
226
|
/** Get all subdirectory paths for a step. */
|
|
217
227
|
export function allStepSubdirs(stepBase) {
|
|
218
228
|
return STEP_SUBDIRS.map((type) => stepSubdir(stepBase, type));
|
package/package.json
CHANGED