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
31 changes: 7 additions & 24 deletions apps/core/src/agent-builds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ import {
draftOverLatest,
latestVersion,
proposeDraft,
proposedByJob,
} from "./apps.ts";
import type { Acting, Member } from "./auth/identity.ts";
import { jobView } from "./build-job-records.ts";
import { reconcileReceipt } from "./build-job-receipts.ts";
import { brokenOff, jobView } from "./build-job-records.ts";
import type { BuildJobView } from "./build-job-records.ts";
import { workspace } from "./durable-objects.ts";
import { appsCollectionId } from "./knowledge/app-entries.ts";
Expand Down Expand Up @@ -457,15 +457,6 @@ const orphanedJobMs = 60_000;
/** Where the engine has an instance that has ended. */
const endedInstances = new Set(["complete", "errored", "terminated"]);

/** How a job that never ran, or whose instance went, ends. */
const jobNotStarted = {
status: "failed",
error: JSON.stringify({
code: "internal.unexpected",
message: "The build job couldn't run. Submit it again.",
}),
} as const;

/**
* Fails the chat's jobs whose record says they run while the engine has
* ended their instance (terminated, errored or complete without their last
Expand Down Expand Up @@ -500,7 +491,9 @@ const failDeadJobs = async (
? Date.now() - createdAt.getTime() > orphanedJobMs
: endedInstances.has(instance.status);
if (dead) {
await chats.endBuildJob(chatId, id, jobNotStarted);
// As one that committed, if it did before it went.
const version = await reconcileReceipt(env, workspaceId, chatId, id);
await chats.endBuildJob(chatId, id, brokenOff(version ?? null));
}
})
);
Expand Down Expand Up @@ -1077,7 +1070,7 @@ export class BuildApi extends WorkerEntrypoint<Env, AgentScope> {
...errorFields(error),
});
await chats
.endBuildJob(chatId, opened.id, jobNotStarted)
.endBuildJob(chatId, opened.id, brokenOff(null))
.catch(logCountFailure);
throw error;
}
Expand Down Expand Up @@ -1111,17 +1104,7 @@ export class BuildApi extends WorkerEntrypoint<Env, AgentScope> {
}
await this.#buildable(by, found.appId);
await failDeadJobs(this.env, workspaceId, chatId);
if (found.version === null && found.intendedTree !== null) {
const version = await proposedByJob(
this.env,
found.appId,
{ parent: found.intendedParent, tree: found.intendedTree },
{ chatId, jobId: found.id }
);
if (version !== undefined) {
await chats.recordBuildReceipt(chatId, found.id, version);
}
}
await reconcileReceipt(this.env, workspaceId, chatId, found.id);
const now = await chats.buildJob(chatId, found.id);
return jobView(now ?? found);
},
Expand Down
44 changes: 44 additions & 0 deletions apps/core/src/build-job-receipts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { ChatId, WorkspaceId } from "@grasp-os/shared/ids";

import { proposedByJob } from "./apps.ts";
import { workspace } from "./durable-objects.ts";

// A build job's receipt (build-jobs.ts): the version it committed. The job
// records the tree it is about to commit before it commits; a version over
// that parent with that tree, proposed by this very job, is found here and
// recorded as its receipt, however the job went on: cut off, refused or
// ended behind its record's back.

/**
* The version the chat's job `jobId` committed, recorded as its receipt
* once found: the one its record names, or the one it recorded it was
* about to commit and did. None when it committed nothing.
*/
export const reconcileReceipt = async (
env: Env,
workspaceId: WorkspaceId,
chatId: ChatId,
jobId: string
): Promise<number | undefined> => {
const chats = workspace(env, workspaceId);
const job = await chats.buildJob(chatId, jobId);
if (job === undefined) {
return undefined;
}
if (job.version !== null) {
return job.version;
}
if (job.intendedTree === null) {
return undefined;
}
const version = await proposedByJob(
env,
job.appId,
{ parent: job.intendedParent, tree: job.intendedTree },
{ chatId: job.chatId, jobId: job.id }
);
if (version !== undefined) {
await chats.recordBuildReceipt(job.chatId, job.id, version);
}
return version;
};
18 changes: 17 additions & 1 deletion apps/core/src/build-job-records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { chatBuildJobs } from "./db/workspace/schema.ts";
// interleaved with another call.

/** Most build jobs a chat may have running at once. */
export const maxRunningJobs = 3;
const maxRunningJobs = 3;

/**
* Most build job records a chat keeps, newest first: what its agent may
Expand All @@ -23,6 +23,22 @@ export const maxRunningJobs = 3;
*/
export const jobRecordsKept = 100;

/**
* How a job that broke off ends, failed: as one that committed version
* `version` when it did, so the agent doesn't propose it again, or as one
* that didn't run to its end, to submit again.
*/
export const brokenOff = (version: number | null): BuildJobEnd => ({
status: "failed",
error: JSON.stringify({
code: "internal.unexpected",
message:
version === null
? "The build job couldn't run to its end. Submit it again."
: `The build job ended unexpectedly after committing version ${version}, which is proposed for review. Don't propose it again.`,
}),
});

/** A build job's row. */
export type BuildJobRow = typeof chatBuildJobs.$inferSelect;

Expand Down
86 changes: 33 additions & 53 deletions apps/core/src/build-jobs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { agentErrors } from "@grasp-os/shared/agent";
import { appErrors } from "@grasp-os/shared/apps";
import { internalErrors, isExpectedError } from "@grasp-os/shared/errors";
import { isExpectedError } from "@grasp-os/shared/errors";
import { chatIdSchema, workspaceIdSchema } from "@grasp-os/shared/ids";
import type { AppId } from "@grasp-os/shared/ids";
import { permissionErrors } from "@grasp-os/shared/permissions";
Expand Down Expand Up @@ -29,11 +29,12 @@ import {
draftFiles,
draftOverLatest,
proposeDraft,
proposedByJob,
versionTree,
} from "./apps.ts";
import type { Acting } from "./auth/identity.ts";
import { memberOf } from "./auth/identity.ts";
import { reconcileReceipt } from "./build-job-receipts.ts";
import { brokenOff } from "./build-job-records.ts";
import type { BuildJobEnd } from "./build-job-records.ts";
import { workspace } from "./durable-objects.ts";
import { grantedPermissions } from "./permissions.ts";
Expand Down Expand Up @@ -69,6 +70,10 @@ import type { BuildJob, Draft } from "./workspace.ts";
// it, granted when that run opened. Each step, and each write right
// before it commits, reads revocations since then (`admitJob`): one
// found ends the job as revoked, and nothing it does after is admitted.
// Any revoked permission of the agent ends it, not only those a build
// uses (decided for GRA-443): a revocation is an admin narrowing the
// agent's authority, which a job mustn't outlive on a guess at which
// grants its work rests on, and a job is cheap to submit again.
// A revoked job hands on no result. What it committed before keeps its
// receipt: it stays, proposed for review, and is never said to be undone.
// - Cancellation: cancelling the turn that submitted it, or deleting the
Expand Down Expand Up @@ -103,7 +108,7 @@ import type { BuildJob, Draft } from "./workspace.ts";
// App's run.

/** What the dispatcher hands a job (`env.build.submit`). */
export const buildJobInputSchema = z.strictObject({
const buildJobInputSchema = z.strictObject({
workspaceId: workspaceIdSchema,
chatId: chatIdSchema,
jobId: z.uuid(),
Expand Down Expand Up @@ -143,10 +148,11 @@ const scopeOf = (
* Admits a step or write of the job `jobId`, right before it: refused once
* the job has ended (cancelled with its turn or chat, or revoked), and
* when a permission of its agent was revoked since its authority was
* granted, which ends it as revoked. The slow read of revocations comes
* first, the job's own check last.
* granted, which ends it as revoked. The job's own check comes first, a
* cheap refusal, and again last, after the slow read of revocations, so
* nothing ended meanwhile is admitted.
*/
export const admitJob = async (
const admitJob = async (
env: Env,
scope: Omit<AgentScope, "runId">,
jobId: string
Expand Down Expand Up @@ -258,7 +264,8 @@ const asJob = async <T>(
const value = await auditedCall(
env,
{ ...scope, runId: job.runId },
{ method, detail: { job: job.id, app: job.appId } },
// The run that submitted it, recorded with each of its calls.
{ method, detail: { job: job.id, app: job.appId, run: job.runId } },
async () => {
const { by, app } = await authorize(env, scope, job);
return await run({ job, scope, app, by });
Expand Down Expand Up @@ -334,36 +341,6 @@ const checkStep = async (env: Env, step: JobStep): Promise<Checked> => {
};
};

/**
* Records, as the job's receipt, the version it recorded it was about to
* commit and did commit, if it did, before anything else of a commit run
* again: whether or not the job may still act, as what it committed stays.
* The version, if there is one.
*/
const reconcileReceipt = async (
env: Env,
input: JobInput
): Promise<number | undefined> => {
const chats = workspace(env, input.workspaceId);
const job = await chats.buildJob(input.chatId, input.jobId);
if (job?.version !== null) {
return job?.version;
}
if (job.intendedTree === null) {
return undefined;
}
const version = await proposedByJob(
env,
job.appId,
{ parent: job.intendedParent, tree: job.intendedTree },
{ chatId: job.chatId, jobId: job.id }
);
if (version !== undefined) {
await chats.recordBuildReceipt(job.chatId, job.id, version);
}
return version;
};

/**
* Commits the job's draft as the App's next version, pending review, as
* `env.build.propose` does once its check passed: admitted right before
Expand Down Expand Up @@ -448,15 +425,6 @@ const completed = (result: DraftCheck | Proposal): BuildJobEnd => ({
result: JSON.stringify(result),
});

/** The job's end for a step that failed for good: internal, unexpected. */
const unexpected = (): BuildJobEnd => {
const error = internalErrors.create("internal.unexpected");
return {
status: "failed",
error: JSON.stringify({ code: error.code, message: error.message }),
};
};

/**
* Runs `run` as the engine's step `name` (`asJob`), journaled: run again
* after a restart only when it never finished.
Expand Down Expand Up @@ -506,7 +474,8 @@ const jobSteps = async (
});
}
const proposed = await jobStep(step, "commit", async () => {
await reconcileReceipt(env, input);
// Before it asks to be admitted: what it committed stays its receipt.
await reconcileReceipt(env, input.workspaceId, input.chatId, input.jobId);
return await asJob(
env,
input,
Expand All @@ -529,18 +498,29 @@ export const runBuildJob = async (
step: RunStep
): Promise<void> => {
const input = buildJobInputSchema.parse(payload);
let end: BuildJobEnd;
// Null when a step failed for good, past its retries. Should the engine
// have stopped this execution instead, the next step throws as well,
// and the engine runs the job again from here.
let end: BuildJobEnd | null;
try {
end = await jobSteps(env, input, step);
} catch {
// A step failed for good, past its retries. Should the engine have
// stopped this execution instead, the next step throws as well, and
// the engine runs the job again from here.
end = unexpected();
end = null;
}
await step.do("finish", stepConfig, async () => {
const chats = workspace(env, input.workspaceId);
let ending = end;
// Broken off: as one that committed, if it did, never as one to submit
// again.
let ending =
end ??
brokenOff(
(await reconcileReceipt(
env,
input.workspaceId,
input.chatId,
input.jobId
)) ?? null
);
if (ending.status === "completed") {
const job = await chats.buildJob(input.chatId, input.jobId);
try {
Expand Down
Loading
Loading