Skip to content
Merged
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
94 changes: 69 additions & 25 deletions src/node/services/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,15 @@
readonly createdAt: string;
}

/** A direct launch's materialized checkout, read by the steps after its entry is persisted. */
interface TaskLaunchCheckout {
readonly initLogger: InitLogger;
readonly workspacePath: string;
readonly trunkBranch: string;
readonly forkedRuntimeConfig: RuntimeConfig;
readonly runtimeForTaskWorkspace: Runtime;
}

/**
* A direct Task.create launch's progress. The launch writes it as it advances and its failure
* path (failTaskLaunch) reads it, so it is one mutable record: a copy would read stale values.
Expand Down Expand Up @@ -6949,7 +6958,7 @@
return Err("Task.create: prompt is required");
}

const normalizedAgentId = normalizeAgentId(args.agentId ?? args.agentType, "");

Check warning on line 6961 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: ?? -> ||

No unit test failed when createUnderTaskTreeLifecycleLock had ?? -> ||. Add a test that tells them apart, or say why they are equivalent.
if (!normalizedAgentId) {
return Err("Task.create: agentId is required");
}
Expand All @@ -6963,16 +6972,16 @@
const bestOf = args.bestOf;
if (bestOf) {
const groupId = coerceNonEmptyString(bestOf.groupId);
if (!groupId) {

Check warning on line 6975 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: delete !

No unit test failed when createUnderTaskTreeLifecycleLock had delete !. Add a test that tells them apart, or say why they are equivalent.
return Err("Task.create: bestOf.groupId is required when bestOf is provided");
}
if (!Number.isInteger(bestOf.index) || bestOf.index < 0) {

Check warning on line 6978 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: || -> &&

No unit test failed when createUnderTaskTreeLifecycleLock had || -> &&. Add a test that tells them apart, or say why they are equivalent.

Check warning on line 6978 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: < -> <=

No unit test failed when createUnderTaskTreeLifecycleLock had < -> <=. Add a test that tells them apart, or say why they are equivalent.

Check warning on line 6978 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: 0 -> 1

No unit test failed when createUnderTaskTreeLifecycleLock had 0 -> 1. Add a test that tells them apart, or say why they are equivalent.

Check warning on line 6978 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: delete !

No unit test failed when createUnderTaskTreeLifecycleLock had delete !. Add a test that tells them apart, or say why they are equivalent.
return Err("Task.create: bestOf.index must be a non-negative integer");
}
if (!Number.isInteger(bestOf.total) || bestOf.total < 2) {

Check warning on line 6981 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: || -> &&

No unit test failed when createUnderTaskTreeLifecycleLock had || -> &&. Add a test that tells them apart, or say why they are equivalent.

Check warning on line 6981 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: < -> <=

No unit test failed when createUnderTaskTreeLifecycleLock had < -> <=. Add a test that tells them apart, or say why they are equivalent.

Check warning on line 6981 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: delete !

No unit test failed when createUnderTaskTreeLifecycleLock had delete !. Add a test that tells them apart, or say why they are equivalent.
return Err("Task.create: bestOf.total must be an integer >= 2");
}
if (bestOf.index >= bestOf.total) {

Check warning on line 6984 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: >= -> >

No unit test failed when createUnderTaskTreeLifecycleLock had >= -> >. Add a test that tells them apart, or say why they are equivalent.
return Err("Task.create: bestOf.index must be less than bestOf.total");
}

Expand Down Expand Up @@ -7529,10 +7538,20 @@
}
return materialized;
}
const { initLogger, workspacePath, trunkBranch, forkedRuntimeConfig, runtimeForTaskWorkspace } =
materialized.data;
return await this.sanitizeTaskLaunchCheckout(plan, launch, materialized.data);
}

if (!useSharedWorkspace) {
/**
* The direct launch after its entry is persisted. Sanitizes the materialized checkout, then
* chains to announceAndInitTask and sendTaskLaunchPrompt: each step starts the next in the same
* tick as its own last await, so no call runs later than it did inline.
*/
private async sanitizeTaskLaunchCheckout(
plan: TaskCreatePlan,
launch: TaskLaunchState,
checkout: TaskLaunchCheckout
): Promise<Result<TaskCreateResult, string>> {
if (!plan.useSharedWorkspace) {
// SECURITY: this checkout materialized outside the host's create/fork paths, so
// registration-time plugin-override sanitization never saw it —
// a tracked stale `plugin:` enable would re-activate a same-name
Expand All @@ -7542,28 +7561,43 @@
// subscriber can send to this running-status task workspace while
// sanitization is still waiting on the override lock.
const sanitizeError = await this.workspaceService.sanitizeMaterializedTaskWorkspace(
taskId,
workspacePath,
forkedRuntimeConfig
plan.taskId,
checkout.workspacePath,
checkout.forkedRuntimeConfig
);
if (sanitizeError !== undefined) {
// The row stays published with this checkout: MCP and sends refuse it (#4674).
this.initStateManager.markCheckoutUnsanitized(taskId);
await this.failTaskLaunch(plan, launch, sanitizeError, runtimeForTaskWorkspace, {});
initLogger.logComplete(-1);
this.initStateManager.markCheckoutUnsanitized(plan.taskId);
await this.failTaskLaunch(
plan,
launch,
sanitizeError,
checkout.runtimeForTaskWorkspace,
{}
);
checkout.initLogger.logComplete(-1);
return Err(sanitizeError);
}
}
return this.announceAndInitTask(plan, launch, checkout);
}

/** Announces the launched task's workspace and kicks its init, then sends the prompt. */
private async announceAndInitTask(
plan: TaskCreatePlan,
launch: TaskLaunchState,
checkout: TaskLaunchCheckout
): Promise<Result<TaskCreateResult, string>> {
const { taskId } = plan;
// Emit metadata update so the UI sees the workspace immediately.
await this.emitWorkspaceMetadata(taskId);

// Kick init (best-effort, async). Shared-workspace (isolation: "none") tasks reuse the parent's
// already-initialized checkout, so re-running init would redundantly (and possibly disruptively)
// mutate the live parent workspace — skip it entirely.
if (!useSharedWorkspace) {
if (!plan.useSharedWorkspace) {
const secrets = await secretsToRecord(
this.secretsStore.getEffectiveSecrets(parentMeta.projectPath)
this.secretsStore.getEffectiveSecrets(plan.parentMeta.projectPath)
);
// Registered (not just fired) with the host's abort-and-settlement mechanism:
// a model-driven archive of this task workspace must be able to cancel the init and
Expand All @@ -7574,25 +7608,35 @@
taskId,
initAbortController,
runBackgroundInit(
runtimeForTaskWorkspace,
checkout.runtimeForTaskWorkspace,
{
projectPath: parentMeta.projectPath,
branchName: workspaceName,
trunkBranch,
workspacePath,
initLogger,
projectPath: plan.parentMeta.projectPath,
branchName: plan.workspaceName,
trunkBranch: checkout.trunkBranch,
workspacePath: checkout.workspacePath,
initLogger: checkout.initLogger,
env: secrets,
abortSignal: initAbortController.signal,
skipInitHook,
skipInitHook: plan.skipInitHook,
trusted:
this.config.loadConfigOrDefault().projects.get(configProjectPath)?.trusted ?? false,
this.config.loadConfigOrDefault().projects.get(plan.configProjectPath)?.trusted ??
false,
},
taskId,
workspaceUseLeasesFor(this.config)
)
);
}
return this.sendTaskLaunchPrompt(plan, launch, checkout);
}

/** Admits the launch's prompt under the attempt it persisted and sends it. */
private async sendTaskLaunchPrompt(
plan: TaskCreatePlan,
launch: TaskLaunchState,
checkout: TaskLaunchCheckout
): Promise<Result<TaskCreateResult, string>> {
const { taskId, taskModelString, effectiveThinkingLevel } = plan;
// Start immediately (counts towards parallel limit). Launch fence, as in startReservedAgentTask:
// the obligation is bound under the owned attempt before dispatch and the token doubles as the
// send's staleness probe. A guarded send is never a user resume, so WorkspaceService skips
Expand All @@ -7617,12 +7661,12 @@
: await this.workspaceService
.sendMessage(
taskId,
prompt,
plan.prompt,
{
model: taskModelString,
agentId,
agentId: plan.agentId,
thinkingLevel: effectiveThinkingLevel,
reasoningMode: effectiveReasoningMode,
reasoningMode: plan.effectiveReasoningMode,
},
{
acceptanceOrigin: "automatic",
Expand All @@ -7637,8 +7681,8 @@
typeof sendResult.error === "string"
? sendResult.error
: formatSendMessageError(sendResult.error).message;
await this.failTaskLaunch(plan, launch, message, runtimeForTaskWorkspace, {
preservePhysicalWorkspace: useSharedWorkspace,
await this.failTaskLaunch(plan, launch, message, checkout.runtimeForTaskWorkspace, {
preservePhysicalWorkspace: plan.useSharedWorkspace,
});
return Err(message);
}
Expand All @@ -7649,7 +7693,7 @@
status: "running",
modelString: taskModelString,
thinkingLevel: effectiveThinkingLevel,
desktopOwnerWorkspaceId: taskDesktopOwnerWorkspaceId ?? taskId,
desktopOwnerWorkspaceId: plan.taskDesktopOwnerWorkspaceId ?? taskId,
});
}

Expand Down
Loading