Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
6a67d7c
Re-vendor Interchange at 4ed8baf4: the workflow.json retirement
TheGreatAxios Aug 20, 2026
5fcd6d6
Folded launch: build the single-step agent from resolved fields
TheGreatAxios Aug 20, 2026
2166d99
Update docs: map the workflow.json retirement's conversion sites
TheGreatAxios Aug 20, 2026
9b096c4
Add tests for onTrigger onBodyFailure and its projection
TheGreatAxios Aug 20, 2026
90cb7d1
onTrigger: restore the onBodyFailure policy and carry it through proj…
TheGreatAxios Aug 20, 2026
ca9c297
Add tests for the adopting code-sourced deploy front
TheGreatAxios Aug 20, 2026
812f58a
hub-sessions: a code-sourced deploy front that adopts an existing anc…
TheGreatAxios Aug 20, 2026
e755985
Update docs: ledger the onBodyFailure projection and the adopting dep…
TheGreatAxios Aug 20, 2026
ac91605
Add tests for the agent-runtime workflow source package
TheGreatAxios Aug 20, 2026
90ce6e3
Agent runtime: one versioned workflow source package, configured per …
TheGreatAxios Aug 20, 2026
fb0b2e0
Agent runtime: render the per-run config into the deployed bytes
TheGreatAxios Aug 20, 2026
a3b62dd
Update docs: the agent-runtime package and what still blocks deployAt…
TheGreatAxios Aug 20, 2026
60999fd
Add tests for the code-sourced folded-run deploy
TheGreatAxios Aug 20, 2026
d2af594
folded-runs: deploy a rendered source package, not a synthesized defi…
TheGreatAxios Aug 20, 2026
d269369
Update docs: deployAtHead is on the code-sourced seam
TheGreatAxios Aug 20, 2026
e41700f
Add tests for the sidecar's closure-sourced deploy
Aug 20, 2026
cb998a8
Sidecar: deploy from the closure, not from workflow.json
Aug 20, 2026
41656d7
Update docs: the sidecar is on closures, and what is still unproven
Aug 20, 2026
fd25d9a
Rebase onto main: reconcile the chat suite with the room-store cutover
TheGreatAxios Aug 20, 2026
6beb69a
seed: push a workflow source codebase, not the retired workflow.json …
TheGreatAxios Aug 20, 2026
1ee8bed
seed: deploy the pushed workflow source by source-ref, not by assetId
TheGreatAxios Aug 20, 2026
92923f5
Update the seed's test doubles for the source-ref deploy body
TheGreatAxios Aug 20, 2026
6d4294e
Update docs: what the first real boot on the new rails found
TheGreatAxios Aug 20, 2026
c2b70f5
Add tests for the persisted projection and the dependency-free run tree
TheGreatAxios Aug 20, 2026
06d51da
Persist a definition's inert projection with the freeze that hashed it
TheGreatAxios Aug 20, 2026
1b17fca
Deploy a folded run from a tree the sidecar can actually resolve
TheGreatAxios Aug 20, 2026
d0bbad2
Add the CL-6324 four-proof harness, and update docs
TheGreatAxios Aug 20, 2026
ea0f2cc
Add tests for the source-form agent-directory authoring
TheGreatAxios Aug 20, 2026
9615e5a
Agent definitions: author the asset as a source codebase
TheGreatAxios Aug 20, 2026
b640c64
Update docs: close out the agent-directory authoring cutover
TheGreatAxios Aug 20, 2026
9430e99
Proofs: assert each deploy shape's own per-message bracket
TheGreatAxios Aug 20, 2026
ea4333f
Update docs: what the four proofs proved, and where proof 4 stops
TheGreatAxios Aug 20, 2026
2d9073b
Proof 4: make the mid-turn kill genuinely mid-turn, and refuse a sile…
TheGreatAxios Aug 20, 2026
43ba0b2
Update docs: why a restored run comes back terminal, and what a relau…
TheGreatAxios Aug 20, 2026
8a31d81
Add tests for the address-to-current-run mapping
Aug 20, 2026
c6af6e8
Relaunch: a dead run is replaced, not resurrected
Aug 20, 2026
6b22bd9
Add tests for the boot sweep, the relaunch notice, and old attachments
TheGreatAxios Aug 20, 2026
f7bdbfa
Relaunch: sweep dead rooms at boot, and tell the room it happened
TheGreatAxios Aug 20, 2026
a8903b8
Update docs: what closing proof 4 needed, and what re-running it found
TheGreatAxios Aug 20, 2026
636e22d
Add tests for the boot-restore workflow-run push hold
TheGreatAxios Aug 20, 2026
0c20a67
Boot restore: hold workflow-run pushes until the link is routable
TheGreatAxios Aug 20, 2026
584eb45
Update docs: the terminal signal was never delivered, not mis-read
TheGreatAxios Aug 20, 2026
2f0204e
Record the re-vendored db and hub-sessions tree hashes
TheGreatAxios Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 85 additions & 46 deletions VENDORED.md

Large diffs are not rendered by default.

76 changes: 63 additions & 13 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
} from "@intx/hub-api";

import {
agentDefinitionSourceTree,
buildAgentDefinitionWorkflow,
createAgentDefinitionRoutes,
createDefinitionAssetHistory,
Expand Down Expand Up @@ -75,6 +76,7 @@ import {
createDrizzleWriteClaimStore,
createHubChatPlatform,
createNoopInferenceRoutes,
createRelaunchNoticePoster,
findExistingAgentChat,
createWorkflowParticipantRoutes,
isWorkbenchHostDefinitionName,
Expand All @@ -84,6 +86,7 @@ import {
startWorkflowCommand,
sendWorkbenchMessage,
} from "@corbits/chat";
import type { RelaunchNoticePort } from "@corbits/chat";
import type { FinalizedTurnToolCall } from "@corbits/turn-artifacts";
import {
createCryptoProviderCache,
Expand Down Expand Up @@ -1041,14 +1044,19 @@ export async function createHub(config: HubConfig) {
createWorkbenchHostInferencePreferencesResolver((tenantId) =>
listDefaultInferencePreferences(db, tenantId),
);
// Where a relaunch announces itself in the room (see `@corbits/chat`'s
// `relaunch-notice.ts`). Armed further down, once the room-message
// store the poster writes through exists — the platform that fires
// notices has to be constructed first, since the sweep that triggers
// most of them hangs off it.
const relaunchNoticeRef: RelaunchNoticePort = {};
const chatPlatform = createHubChatPlatform({
db,
sessionService,
assetService,
sidecarRouter,
eventCollectors,
credentialCipher,
hubPublicKey,
toolGrantsForPins,
mcpCredentialBindingsFor,
noopInferenceBaseUrl: `${config.baseUrl}/api/chat/noop-inference`,
Expand All @@ -1067,6 +1075,7 @@ export async function createHub(config: HubConfig) {
// tenant-catalog default, instead of 409ing `not_launchable`.
workbenchHostInferencePreferences:
workbenchHostInferencePreferencesResolver,
relaunchNotice: relaunchNoticeRef,
});
wireMailRedelivery({ sidecarRouter, chatPlatform });
// The one SSE subscriber registry for this process's workbench events
Expand All @@ -1093,6 +1102,11 @@ export async function createHub(config: HubConfig) {
// The room timeline store (CL-6327): a workbench's own messages, held
// as workbench data rather than platform mail.
const roomMessages = createDrizzleRoomMessageStore(db);
relaunchNoticeRef.current = createRelaunchNoticePoster({
store: chatStore,
roomMessages,
publish: workbenchSubscribers.publish,
});
// Built once, beside the platform, for the process's lifetime: turns
// an invited agent's `connector.reply` events into workbench messages,
// and a gate-blocked run's approval park into an in-chat approve
Expand Down Expand Up @@ -1120,6 +1134,53 @@ export async function createHub(config: HubConfig) {
chatOrchestratorDeps.memory = memoryHandle.memory;
}
const chatOrchestrator = createChatOrchestrator(chatOrchestratorDeps);
// A room participant that died with its sidecar is otherwise silently
// dead until somebody writes into it, and the turn the crash
// interrupted never surfaces at all — the run that died never sends
// the `message.run.ended` the orchestrator's turn-drop notice hangs
// off. The sweep finds those runs and relaunches each one, posting
// its notice.
//
// A series of passes rather than one, because "this run is dead" is
// not knowable at the instant the execution plane comes back: the
// terminal event is committed to the run's durable log by the dying
// sidecar and reaches `workflow_run.status` only once the restarted
// sidecar has packed it back to the hub, seconds later. The series is
// bounded and re-armed by a sidecar disconnect, which is the one
// event that can newly orphan a room.
const relaunchSweepLog = getLogger(["chat", "relaunch-sweep"]);
const RELAUNCH_SWEEP_DELAYS_MS = [0, 2_000, 5_000, 15_000, 45_000];
// Bumped by every reschedule so a pass still in flight from the
// previous series retires instead of continuing beside the new one.
let relaunchSweepSeries = 0;
let relaunchSweepTimer: ReturnType<typeof setTimeout> | undefined;
function runNextRelaunchSweepPass(series: number, pass: number): void {
const delay = RELAUNCH_SWEEP_DELAYS_MS[pass];
if (delay === undefined || series !== relaunchSweepSeries) return;
const timer = setTimeout(() => {
void chatPlatform
.sweepTerminalRuns()
.catch((cause: unknown) => {
relaunchSweepLog.error`relaunch sweep pass failed: ${
cause instanceof Error ? cause.message : String(cause)
}`;
})
.finally(() => {
runNextRelaunchSweepPass(series, pass + 1);
});
}, delay);
timer.unref?.();
relaunchSweepTimer = timer;
}
function scheduleRelaunchSweep(): void {
clearTimeout(relaunchSweepTimer);
relaunchSweepSeries += 1;
runNextRelaunchSweepPass(relaunchSweepSeries, 0);
}
scheduleRelaunchSweep();
sidecarRouter.events.on("sidecar.disconnect", () => {
scheduleRelaunchSweep();
});
// Now that `chatStore`/`chatPlatform` exist, arm the finalized-turn
// artifact-delivery ref declared beside `eventCollectors` above.
// `memory` (absent when the plane isn't mounted) lets this handler
Expand Down Expand Up @@ -1650,7 +1711,6 @@ export async function createHub(config: HubConfig) {
assetService,
sidecarRouter,
eventCollectors,
hubPublicKey,
toolGrantsForPins,
mcpCredentialBindingsFor,
cryptoProviderCache: foldedRunCryptoProviders,
Expand Down Expand Up @@ -2198,7 +2258,6 @@ export async function createHub(config: HubConfig) {
sidecarRouter,
eventCollectors,
credentialCipher,
hubPublicKey,
toolGrantsForPins,
mcpCredentialBindingsFor,
cryptoProviderCache: foldedRunCryptoProviders,
Expand Down Expand Up @@ -2571,13 +2630,6 @@ export async function createHub(config: HubConfig) {
listModels: listMyraModels,
};

// Mirrors `@corbits/agent-directory`'s own private
// `AGENT_DEFINITION_ASSET_PATH` constant (not exported — the route
// module keeps it internal), kept in lockstep by convention since
// this is the same asset-tree contract `ensureWorkflowDefinitionForAsset`
// reads back from.
const PLANNER_AGENT_DEFINITION_ASSET_PATH = "workflow.json";

/**
* Wraps the same sequence `@corbits/agent-directory`'s `POST /`
* handler runs (`buildAgentDefinitionWorkflow` → `reindexPinnedSkills`
Expand Down Expand Up @@ -2659,9 +2711,7 @@ export async function createHub(config: HubConfig) {
ref: DEFAULT_ASSET_REF,
principal: { kind: "hub" },
tree: {
files: {
[PLANNER_AGENT_DEFINITION_ASSET_PATH]: workflowJson,
},
files: agentDefinitionSourceTree({ handle, workflowJson }),
message: `Define agent ${input.name}`,
},
});
Expand Down
11 changes: 2 additions & 9 deletions apps/hub/src/routine-launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// goes through — and that a delivery failure past launch never un-does
// or hides the already-real run. `@corbits/folded-runs` is real here
// except for `launchFoldedRun`/`sendFoldedMailWithRetry`/
// `readDefinitionJSON`, which would otherwise need a real tenant catalog
// `readDefinitionProjection`, which would otherwise need a real tenant catalog
// and asset store — the same "swap the one export that needs a join"
// approach `packages/folded-runs/test/launch.test.ts` and
// `packages/webhook-triggers/test/launch.test.ts` use.
Expand Down Expand Up @@ -33,7 +33,7 @@ let sendFoldedMailWithRetryResult: unknown = {

mock.module("@corbits/folded-runs", () => ({
...actualFoldedRuns,
readDefinitionJSON: async () => ({ __fake: true }),
readDefinitionProjection: async () => ({ __fake: true }),
readFoldedBody: () => FOLDED_BODY,
launchFoldedRun: async (...args: unknown[]) => {
launchFoldedRunCalls.push(args);
Expand Down Expand Up @@ -114,7 +114,6 @@ function buildLauncher(overrides: { definition?: unknown } = {}) {
sessionService: {} as never,
assetService: {} as never,
sidecarRouter: {} as never,
hubPublicKey: "hub-key",
toolGrantsForPins: () => [],
eventCollectors: {} as never,
cryptoProviderCache: { get: async () => ({}) as never },
Expand Down Expand Up @@ -232,7 +231,6 @@ describe("createHubRoutineLauncher — delivery workbench", () => {
sessionService: {} as never,
assetService: {} as never,
sidecarRouter: {} as never,
hubPublicKey: "hub-key",
toolGrantsForPins: () => [],
eventCollectors: {} as never,
cryptoProviderCache: { get: async () => ({}) as never },
Expand All @@ -259,7 +257,6 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => {
sessionService: {} as never,
assetService: {} as never,
sidecarRouter: {} as never,
hubPublicKey: "hub-key",
toolGrantsForPins: () => [],
eventCollectors: {} as never,
cryptoProviderCache: { get: async () => ({}) as never },
Expand Down Expand Up @@ -297,7 +294,6 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => {
sessionService: {} as never,
assetService: {} as never,
sidecarRouter: {} as never,
hubPublicKey: "hub-key",
toolGrantsForPins: () => [],
eventCollectors: {} as never,
cryptoProviderCache: { get: async () => ({}) as never },
Expand All @@ -324,7 +320,6 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => {
sessionService: {} as never,
assetService: {} as never,
sidecarRouter: {} as never,
hubPublicKey: "hub-key",
toolGrantsForPins: () => [],
eventCollectors: {} as never,
cryptoProviderCache: { get: async () => ({}) as never },
Expand Down Expand Up @@ -356,7 +351,6 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => {
sessionService: {} as never,
assetService: {} as never,
sidecarRouter: {} as never,
hubPublicKey: "hub-key",
toolGrantsForPins: () => [],
eventCollectors: {} as never,
cryptoProviderCache: { get: async () => ({}) as never },
Expand All @@ -378,7 +372,6 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => {
sessionService: {} as never,
assetService: {} as never,
sidecarRouter: {} as never,
hubPublicKey: "hub-key",
toolGrantsForPins: () => [],
eventCollectors: {} as never,
cryptoProviderCache: { get: async () => ({}) as never },
Expand Down
10 changes: 5 additions & 5 deletions apps/hub/src/routine-launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import { tenant as tenantTable, workflowDefinition } from "@intx/db/schema";
import {
domainOf,
launchFoldedRun,
readDefinitionJSON,
readDefinitionProjection,
readFoldedBody,
sendFoldedMailWithRetry,
type CryptoProviderCache,
Expand Down Expand Up @@ -162,11 +162,11 @@ export function createHubRoutineLauncher(
throw new Error(`no tenant "${input.tenantId}"`);
}

const definitionJSON = await readDefinitionJSON(
deps.assetService,
definitionRow.assetId,
const projection = await readDefinitionProjection(deps.db, definitionRow);
const foldedBody = readFoldedBody(
projection,
definitionRow.grantRequirements,
);
const foldedBody = readFoldedBody(definitionJSON);

const instanceId = generateId("workflowRun");
const triggerAddress = formatRunAddress(instanceId, tenantRow.domain);
Expand Down
21 changes: 21 additions & 0 deletions apps/sidecar/bin/workflow-probe-child
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env bun
// One-shot workflow-probe child. The sidecar host spawns this by path
// (`Bun.spawn([binaryPath])`).
//
// The child reads the materialized package dir and IPC anchors from its
// fresh env, evaluates the workflow entry behind the airlock, and ships
// one HMAC-signed result frame on stdout. An evaluation failure is shipped
// as an `ok: false` frame (handled inside the runner), so a throw reaching
// here is a pre-evaluation defect (bad env, unwritable stdout) that exits
// non-zero -- the host then reaps and answers `workflow.probe.error`.
import { runWorkflowProbeChildFromProcessEnv } from "../src/workflow-probe-handler";

try {
await runWorkflowProbeChildFromProcessEnv();
process.exit(0);
} catch (err) {
process.stderr.write(
`workflow probe child failed: ${err instanceof Error ? err.message : String(err)}\n`,
);
process.exit(1);
}
50 changes: 50 additions & 0 deletions apps/sidecar/src/boot-restore-push-hold.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test";

import { createBootRestorePushHold } from "./boot-restore-push-hold";

function recordingStore() {
const held: string[] = [];
return {
held,
markAddressUnroutable(agentAddress: string) {
held.push(agentAddress);
},
};
}

describe("createBootRestorePushHold", () => {
test("holds every address a boot restore registers", () => {
const store = recordingStore();
const hold = createBootRestorePushHold(store);

hold.begin();
hold.onDeploymentRegistered("run_a@bench.localhost");
hold.onDeploymentRegistered("run_b@bench.localhost");
hold.end();

expect(store.held).toEqual([
"run_a@bench.localhost",
"run_b@bench.localhost",
]);
});

test("does not hold a deployment that arrives over a live link", () => {
const store = recordingStore();
const hold = createBootRestorePushHold(store);

hold.begin();
hold.end();
hold.onDeploymentRegistered("run_c@bench.localhost");

expect(store.held).toEqual([]);
});

test("holds nothing before the restore is armed", () => {
const store = recordingStore();
const hold = createBootRestorePushHold(store);

hold.onDeploymentRegistered("run_d@bench.localhost");

expect(store.held).toEqual([]);
});
});
43 changes: 43 additions & 0 deletions apps/sidecar/src/boot-restore-push-hold.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Boot restore spawns each restored deployment's supervisor BEFORE
// `hubLink.connect()`, and a supervisor that finds a run whose step died
// mid-invocation commits that run's `StepFailed`/`RunFailed` immediately.
// Those commits schedule a workflow-run pack push into a link that does not
// exist yet, so the push fails "Connection lost" and its only recovery is the
// post-challenge re-drive, which re-ships a slot whose error has already
// latched. When the rejection lands AFTER the challenge fired, nothing
// re-arms the slot: the terminal event stays on sidecar disk, the hub's copy
// of the durable log stops at the last pre-crash event, and
// `workflow_run.status` stays "running" for a run that will never accept mail
// again.
//
// Holding every address registered during boot restore until the reconnect
// challenge proves it routable removes the race rather than recovering from
// it — the same block the link already applies across a mid-life disconnect.
import type { WorkflowRunPackPushingRepoStore } from "./workflow-run-pack-client";

export interface BootRestorePushHold {
/** Arm the hold for the duration of `restoreWorkflowDeployments`. */
begin(): void;
/** Disarm it; deployments registered afterwards arrive over a live link. */
end(): void;
/** Called for every deployment registration the deploy router makes. */
onDeploymentRegistered(agentAddress: string): void;
}

export function createBootRestorePushHold(
store: Pick<WorkflowRunPackPushingRepoStore, "markAddressUnroutable">,
): BootRestorePushHold {
let restoring = false;
return {
begin() {
restoring = true;
},
end() {
restoring = false;
},
onDeploymentRegistered(agentAddress) {
if (!restoring) return;
store.markAddressUnroutable(agentAddress);
},
};
}
Loading
Loading