Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 4 additions & 4 deletions FORK.md

Large diffs are not rendered by default.

13 changes: 1 addition & 12 deletions apps/server/src/j5/a2a/SquadronLaunchPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { describe, expect, it } from "vite-plus/test";

import {
DV5_NATIVE_COHORTS,
DV5_SCHEDULED_NEW_THREAD_POLICY,
resolveSquadronLaunchPolicy,
} from "./SquadronLaunchPolicy.ts";
import { DV5_NATIVE_COHORTS, resolveSquadronLaunchPolicy } from "./SquadronLaunchPolicy.ts";

describe("resolveSquadronLaunchPolicy", () => {
it("requires an explicit Squadron for interactive user-origin creation", () => {
Expand Down Expand Up @@ -39,13 +35,6 @@ describe("resolveSquadronLaunchPolicy", () => {
},
);

it("names scheduled new-thread execution as unsupported instead of guessing scheduler provenance", () => {
expect(DV5_SCHEDULED_NEW_THREAD_POLICY).toMatchObject({
kind: "unsupported-refused",
returnCondition: "Return with future scheduling context selection.",
});
});

it("requires a Squadron when a system/server launch has an initial message", () => {
expect(
resolveSquadronLaunchPolicy({
Expand Down
43 changes: 36 additions & 7 deletions apps/server/src/j5/a2a/SquadronLaunchPolicy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import type { OrchestrationV2Actor, OrchestrationV2CreationSource } from "@t3tools/contracts";
import type {
OrchestrationV2Actor,
OrchestrationV2CreationSource,
ProjectId,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import type * as SqlClient from "effect/unstable/sql/SqlClient";

import { listSquadronReferencesForProject } from "./SquadronProjectReferences.ts";

/**
* DV5's closed native/nonparticipant table. The agent-spawn row is an
Expand All @@ -14,12 +23,32 @@ export const DV5_NATIVE_COHORTS = {
"Return through A2's spawn_agent verb; do not route OrchestratorMcp delegate_task through this launch policy.",
} as const;

export const DV5_SCHEDULED_NEW_THREAD_POLICY = {
kind: "unsupported-refused" as const,
message:
"Scheduled new-thread execution is unsupported until scheduling context can select an explicit existing Squadron.",
returnCondition: "Return with future scheduling context selection.",
};
export class ScheduledLaunchSharedProjectError extends Schema.TaggedError<ScheduledLaunchSharedProjectError>()(
"ScheduledLaunchSharedProjectError",
{ projectId: Schema.String, squadronCount: Schema.Number },
) {
override get message(): string {
return `Project ${this.projectId} is shared by ${this.squadronCount} Squadrons, and a scheduled task cannot choose between them, so no thread was started. Bind the task to an existing thread, or leave the project with one Squadron.`;
}
}

/**
* A scheduled new-thread run sends no Squadron, so its thread joins its
* project's. When several Squadrons reference the project the launch would be
* refused only after the thread exists; this refuses first, so a recurring
* task does not leave a homeless thread behind on every fire.
*/
export const refuseScheduledLaunchIntoSharedProject = Effect.fn(
"j5.a2a.refuseScheduledLaunchIntoSharedProject",
)(function* (sql: SqlClient.SqlClient, projectId: ProjectId) {
const references = yield* listSquadronReferencesForProject(sql, projectId);
if (references.length > 1) {
return yield* new ScheduledLaunchSharedProjectError({
projectId,
squadronCount: references.length,
});
}
});

export type SquadronLaunchPolicy =
| { readonly kind: "require-squadron" }
Expand Down
26 changes: 15 additions & 11 deletions apps/server/src/j5/a2a/SquadronProjectReferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,19 @@ const referenceFromRow = (row: SquadronProjectReferenceRow): SquadronProjectRefe
createdAt: row.created_at,
});

/** Every Squadron that references the project, oldest first. */
export const listSquadronReferencesForProject = Effect.fn(
"j5.a2a.squadronProjectReferences.listForProject",
)(function* (sql: SqlClient.SqlClient, projectId: ProjectId) {
const rows = yield* sql<SquadronProjectReferenceRow>`
SELECT squadron_id, project_id, ordinal, created_at
FROM j5_a2a_squadron_project_reference
WHERE project_id = ${projectId}
ORDER BY created_at, squadron_id
`;
return rows.map(referenceFromRow);
});

export const layer: Layer.Layer<SquadronProjectReferences, never, SqlClient.SqlClient> =
Layer.effect(
SquadronProjectReferences,
Expand Down Expand Up @@ -103,17 +116,8 @@ export const layer: Layer.Layer<SquadronProjectReferences, never, SqlClient.SqlC
},
);

const listForProject = Effect.fn("j5.a2a.squadronProjectReferences.listForProject")(
function* (projectId: ProjectId) {
const rows = yield* sql<SquadronProjectReferenceRow>`
SELECT squadron_id, project_id, ordinal, created_at
FROM j5_a2a_squadron_project_reference
WHERE project_id = ${projectId}
ORDER BY created_at, squadron_id
`;
return rows.map(referenceFromRow);
},
);
const listForProject = (projectId: ProjectId) =>
listSquadronReferencesForProject(sql, projectId);

const replaceForSquadron = Effect.fn("j5.a2a.squadronProjectReferences.replaceForSquadron")(
function* (input: ReplaceSquadronProjectReferencesInput) {
Expand Down
164 changes: 132 additions & 32 deletions apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,18 @@ import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistr
import * as ServerSettings from "../serverSettings.ts";
import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts";
import * as TextGeneration from "../textGeneration/TextGeneration.ts";
import { layer as homeRegistrarLayer } from "../j5/a2a/HomeRegistrar.ts";
import { A2ALedger, layer as a2aLedgerLayer } from "../j5/a2a/LedgerService.ts";
import {
SquadronProjectReferences,
layer as squadronProjectReferencesLayer,
} from "../j5/a2a/SquadronProjectReferences.ts";
import {
SquadronThreadCreationAmbiguousProjectError,
SquadronThreadCreationService,
layer as squadronThreadCreationServiceLayer,
} from "../j5/a2a/SquadronThreadCreationService.ts";
import { SquadronId } from "../j5/a2a/contracts.ts";
import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
import * as CommandReceiptStore from "./CommandReceiptStore.ts";
import * as EffectOutbox from "./EffectOutbox.ts";
Expand Down Expand Up @@ -109,6 +117,8 @@ interface HarnessOptions {
readonly providers?: ReadonlyArray<ServerProvider>;
readonly registerAtDurableLaunch?: SquadronThreadCreationService["Service"]["registerAtDurableLaunch"];
readonly findRegisteredHome?: SquadronThreadCreationService["Service"]["findRegisteredHome"];
/** J5: run launches against the real Squadron registration over the harness database. */
readonly realSquadronRegistration?: boolean;
}

function makeHarness(options: HarnessOptions = {}) {
Expand Down Expand Up @@ -181,26 +191,6 @@ function makeHarness(options: HarnessOptions = {}) {
ServerSettings.layerTest(options.serverSettings),
makeProviderRegistryLayer(options.providers),
);
const launch = ThreadLaunch.layer.pipe(
Layer.provide(
Layer.mergeAll(
externalServices,
threadManagement,
receipts,
IdAllocator.layer,
Layer.mock(SquadronThreadCreationService)({
registerAtDurableLaunch:
options.registerAtDurableLaunch ??
(() =>
Effect.succeed({
squadronId: "squadron:launch-test" as never,
participantId: "agent:launch-test" as never,
})),
findRegisteredHome: options.findRegisteredHome ?? (() => Effect.succeed(null)),
}),
),
),
);
const projectedProjects = Layer.mock(ProjectionProjectRepository)({
getById: ({ projectId: requestedProjectId }) =>
Effect.succeed(
Expand All @@ -220,6 +210,40 @@ function makeHarness(options: HarnessOptions = {}) {
: Option.none(),
),
});
const squadronLedger = a2aLedgerLayer.pipe(Layer.provide(database));
const squadronReferences = squadronProjectReferencesLayer.pipe(Layer.provide(database));
const squadronCreation =
options.realSquadronRegistration === true
? squadronThreadCreationServiceLayer.pipe(
Layer.provide(
homeRegistrarLayer.pipe(Layer.provide(squadronLedger), Layer.provide(database)),
),
Layer.provide(squadronLedger),
Layer.provide(squadronReferences),
Layer.provide(projectedProjects),
Layer.provide(database),
)
: Layer.mock(SquadronThreadCreationService)({
registerAtDurableLaunch:
options.registerAtDurableLaunch ??
(() =>
Effect.succeed({
squadronId: "squadron:launch-test" as never,
participantId: "agent:launch-test" as never,
})),
findRegisteredHome: options.findRegisteredHome ?? (() => Effect.succeed(null)),
});
const launch = ThreadLaunch.layer.pipe(
Layer.provide(
Layer.mergeAll(
externalServices,
threadManagement,
receipts,
IdAllocator.layer,
squadronCreation,
),
),
);
const titleRegeneration = ThreadTitleRegeneration.layer.pipe(
Layer.provide(Layer.mergeAll(threadManagement, projectedProjects, externalServices)),
);
Expand All @@ -231,6 +255,9 @@ function makeHarness(options: HarnessOptions = {}) {
outbox,
database,
externalServices,
squadronLedger,
squadronReferences,
squadronCreation,
),
createWorktree,
renameBranch,
Expand Down Expand Up @@ -455,6 +482,91 @@ it.effect("keeps a proposed-plan child of a no-home legacy parent native", () =>
}).pipe(Effect.provide(harness.layer));
});

const scheduledNewThreadTask = {
title: "Daily audit",
prompt: "Audit performance and crashes.",
enabled: false,
schedule: { type: "interval", everyMs: 60_000 },
projectId,
threadId: null,
workspaceStrategy: { type: "root" },
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
} as const;

const scheduledTasksOver = (harness: ReturnType<typeof makeHarness>) =>
ScheduledTasks.layer.pipe(
Layer.provide(Layer.mergeAll(harness.layer, NodeCrypto.layer, Scheduler.layer)),
);

it.effect("a scheduled new-thread run creates its project's Squadron and registers there", () => {
const harness = makeHarness({ realSquadronRegistration: true });
return Effect.gen(function* () {
const tasks = yield* ScheduledTasks.ScheduledTaskService;
const threads = yield* ThreadManagement.ThreadManagementService;
const ledger = yield* A2ALedger;
const references = yield* SquadronProjectReferences;
const creation = yield* SquadronThreadCreationService;

const { task } = yield* tasks.upsert(scheduledNewThreadTask);
const first = yield* tasks.runNow({ id: task.id });
yield* TestClock.adjust(1);
const second = yield* tasks.runNow({ id: task.id });

assert.deepEqual(
[first.task.lastRunStatus, second.task.lastRunStatus],
["succeeded", "succeeded"],
);
const squadrons = yield* ledger.listSquadrons();
assert.deepEqual(
squadrons.map((squadron) => squadron.name),
[project.title],
);
assert.lengthOf(yield* references.listForProject(projectId), 1);
const launched = yield* threads.listProjectThreads({ projectId, includeSubagents: false });
assert.lengthOf(launched, 2);
for (const thread of launched) {
const home = yield* creation.findRegisteredHome(thread.id);
assert.equal(home?.squadronId, squadrons[0]?.id);
}
}).pipe(Effect.provide(Layer.mergeAll(scheduledTasksOver(harness), harness.layer)));
});

it.effect(
"a scheduled new-thread run is refused before a thread exists when Squadrons share its project",
() => {
const harness = makeHarness({ realSquadronRegistration: true });
return Effect.gen(function* () {
const tasks = yield* ScheduledTasks.ScheduledTaskService;
const threads = yield* ThreadManagement.ThreadManagementService;
const ledger = yield* A2ALedger;
const references = yield* SquadronProjectReferences;
const createdAt = "2026-10-03T00:00:00.000Z";
for (const name of ["first", "second"]) {
const id = SquadronId.make(`squadron:scheduled:${name}`);
yield* ledger.createSquadron({ squadron: { id, name, createdAt } });
yield* references.replaceForSquadron({
squadronId: id,
projectIds: [projectId],
createdAt,
});
}

const { task } = yield* tasks.upsert(scheduledNewThreadTask);
const result = yield* tasks.runNow({ id: task.id });

assert.equal(result.task.lastRunStatus, "failed");
assert.match(result.task.lastRunError ?? "", /shared by 2 Squadrons/);
assert.include(result.task.lastRunError ?? "", projectId);
assert.deepEqual(
yield* threads.listProjectThreads({ projectId, includeSubagents: false }),
[],
);
}).pipe(Effect.provide(Layer.mergeAll(scheduledTasksOver(harness), harness.layer)));
},
);

for (const target of ["new", "existing"] as const) {
for (const createdBy of ["user", "agent"] as const) {
it.effect(
Expand Down Expand Up @@ -490,18 +602,6 @@ for (const target of ["new", "existing"] as const) {
creationSource: createdBy === "agent" ? "mcp" : "web",
});
const result = yield* tasks.runNow({ id: task.id });
if (target === "new") {
assert.equal(result.task.lastRunStatus, "failed");
assert.match(
result.task.lastRunError ?? "",
/Scheduled new-thread execution is unsupported/,
);
assert.deepEqual(
yield* threads.listProjectThreads({ projectId, includeSubagents: false }),
[],
);
return;
}
assert.equal(result.task.lastRunStatus, "succeeded");
const projectThreads = yield* threads.listProjectThreads({
projectId,
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/provider/T3OrchestrationInstructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ describe("T3 orchestration provider instructions", () => {
it("documents structured schedules instead of JSON strings", () => {
assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "structured object, never as JSON text");
assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, '"everyMs":3600000');
assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "bindToCurrentThread=false");
assert.include(
T3_CODE_ORCHESTRATION_INSTRUCTIONS,
"set `bindToCurrentThread=false` only when the user wants a fresh thread for every run",
);
assert.notInclude(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "explicit Squadron selection");
});

it("creates mixed crews from chat and keeps coordination independent of approvals and artifacts", () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/T3OrchestrationInstructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ ${AGENT_INVOCATION_INSTRUCTIONS}
- A Crew is a group of Peer Agents you command as Captain for one bounded task. Use \`propose_crew\` when the user asks for a crew in ordinary chat or the work splits into distinct responsibilities that should run at once. Mix saved personas from \`list_personas\` with custom seats: set persona for a saved persona, or omit it and provide task-specific instructions for a custom seat. Custom seats inherit your harness, model, and reasoning by default and run with full access unless you set \`runtime_mode\`; you can select their harness, model, reasoning, and access with \`model_selection\` (instanceId, model, options) and \`runtime_mode\` from \`orchestrator_capabilities\`. Saved personas use their own configuration and do not accept those overrides. The app shows each seat's resolved provider, model, reasoning, and access before the user approves, edits, or declines the roster. Approved seats spawn under you, and each seat's finish reaches you as a message.
- Crew members, their Captain, and Captains of other crews can coordinate directly with \`send_message\`: share findings, concerns, questions, evidence, and final results when they would change the other agent's work, grouped into one message rather than sent as each one happens. A busy receiver handles each message as its own turn after its current one ends. Do not wait for an artifact or a separate coordination approval. Return results in messages unless the user or a selected persona explicitly requires an artifact; that output requirement never blocks conversation. If a concern needs expertise missing from the roster, the member tells its Captain, and the Captain uses \`request_crew_member\` with the crew_instance_id and a clear reason naming the concern and needed responsibility. The user approves additions in the existing inbox; continue the already-approved work and coordination while that request is pending.
- Use \`list_participants\` to resolve an already-addressable agent or the human. Use \`send_message\` without expect_reply for updates or results that need no response. Only for later work owed by an existing participant, use \`send_message(..., expect_reply=true, intent="...")\` to open an Exchange. The reply arrives later as an incoming message; continue with other work instead of polling or holding a turn open to wait. Messages reach a supported active turn through provider steering; otherwise they queue for its next turn.
- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule. Runs return to the current thread; \`bindToCurrentThread=false\` is unavailable until scheduling supports explicit Squadron selection. After scheduling, report the returned cadence and next run time.
- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule. By default runs return to the current thread; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling, report the returned cadence and next run time.
- Use \`write_artifact\` for durable, user-consumable planning outputs that should remain available across threads and agents in the project, such as plans, specifications, diagrams, and research notes. Use \`list_artifacts\` and \`read_artifact\` to access existing artifacts. Do not use artifacts for source code, build output, logs, temporary scratch files, or ordinary repository documentation. Refer to a saved artifact in chat by the logical path returned by \`write_artifact\` (for example \`artifacts/plan.md\`) so J5 opens it in the Artifacts panel. J5 stores artifacts in application data, outside the repository. Artifacts under \`handoffs/\` are versioned: rewriting one adds your content as a new version at the top of the same file rather than replacing it, and reading it back returns a versions header followed by every kept version, newest first.

Playbooks guide work in this thread:
Expand Down
Loading
Loading