Merge pull request #351 from pikasTech/fix/r6-5-session-durable-identity
Pipelines as Code CI / agentrun-nc01-v02-ci-7585787e5e1fae7364d65759267fc0a115fe17d7 Success
Pipelines as Code CI / agentrun-nc01-v02-ci-7585787e5e1fae7364d65759267fc0a115fe17d7 Success
fix(cli): 取消态 Aipod 会话继承持久身份
This commit is contained in:
+71
-1
@@ -1570,7 +1570,13 @@ async function submitQueueTaskWithAipod(args: ParsedArgs, aipod: string): Promis
|
|||||||
|
|
||||||
async function sessionSendWithAipod(args: ParsedArgs, positionalSessionId: string | null, aipod: string): Promise<JsonRecord> {
|
async function sessionSendWithAipod(args: ParsedArgs, positionalSessionId: string | null, aipod: string): Promise<JsonRecord> {
|
||||||
const sessionId = positionalSessionId ?? optionalFlag(args, "session-id") ?? newSessionId();
|
const sessionId = positionalSessionId ?? optionalFlag(args, "session-id") ?? newSessionId();
|
||||||
const rendered = await renderAipodForCommand(args, aipod, positionalSessionId ? 3 : 2, { sessionId });
|
const existing = await fetchDurableSessionForAipodSend(args, sessionId);
|
||||||
|
const renderInput = inheritDurableSessionAipodIdentity(
|
||||||
|
await aipodRenderInput(args, positionalSessionId ? 3 : 2, { sessionId }),
|
||||||
|
sessionId,
|
||||||
|
existing,
|
||||||
|
);
|
||||||
|
const rendered = await client(args).post(`/api/v1/aipod-specs/${encodeURIComponent(aipod)}/render`, renderInput) as RenderedAipodQueueTask;
|
||||||
const task = rendered.queueTask;
|
const task = rendered.queueTask;
|
||||||
const profile = String(task.backendProfile);
|
const profile = String(task.backendProfile);
|
||||||
const sessionRef = objectField(task as unknown as JsonRecord, "sessionRef", {});
|
const sessionRef = objectField(task as unknown as JsonRecord, "sessionRef", {});
|
||||||
@@ -1605,6 +1611,70 @@ async function sessionSendWithAipod(args: ParsedArgs, positionalSessionId: strin
|
|||||||
return { ...summarizeSessionSendResult(result, sessionId, profile, aipod), modelResolution: rendered.modelResolution };
|
return { ...summarizeSessionSendResult(result, sessionId, profile, aipod), modelResolution: rendered.modelResolution };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchDurableSessionForAipodSend(args: ParsedArgs, sessionId: string): Promise<JsonRecord | null> {
|
||||||
|
try {
|
||||||
|
return await client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}`) as JsonRecord;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AgentRunError && error.httpStatus === 404) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inheritDurableSessionAipodIdentity(
|
||||||
|
renderInput: RenderAipodInput,
|
||||||
|
sessionId: string,
|
||||||
|
existing: JsonRecord | null,
|
||||||
|
): RenderAipodInput {
|
||||||
|
if (existing === null) return renderInput;
|
||||||
|
const durableSessionId = stringValue(existing.sessionId);
|
||||||
|
const tenantId = stringValue(existing.tenantId);
|
||||||
|
const projectId = stringValue(existing.projectId);
|
||||||
|
if (durableSessionId !== sessionId || tenantId === null || projectId === null) {
|
||||||
|
throw new AgentRunError("schema-invalid", `session ${sessionId} durable identity is incomplete or mismatched`, {
|
||||||
|
httpStatus: 400,
|
||||||
|
details: { sessionId, valuesPrinted: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assertDurableSessionBoundary(renderInput.tenantId, tenantId, sessionId, "tenantId");
|
||||||
|
assertDurableSessionBoundary(renderInput.projectId, projectId, sessionId, "projectId");
|
||||||
|
const requestedSessionRef = jsonRecordValue(renderInput.sessionRef) ?? {};
|
||||||
|
const requestedSessionId = stringValue(requestedSessionRef.sessionId);
|
||||||
|
if (requestedSessionId !== null && requestedSessionId !== sessionId) {
|
||||||
|
throw durableSessionBoundaryError(sessionId, "sessionRef.sessionId");
|
||||||
|
}
|
||||||
|
const conversationId = stringValue(existing.conversationId);
|
||||||
|
const threadId = stringValue(existing.threadId);
|
||||||
|
const expiresAt = stringValue(existing.expiresAt);
|
||||||
|
return {
|
||||||
|
...renderInput,
|
||||||
|
tenantId,
|
||||||
|
projectId,
|
||||||
|
sessionId,
|
||||||
|
sessionRef: {
|
||||||
|
sessionId,
|
||||||
|
...(conversationId === null ? {} : { conversationId }),
|
||||||
|
...(threadId === null ? {} : { threadId }),
|
||||||
|
...(expiresAt === null ? {} : { expiresAt }),
|
||||||
|
metadata: {
|
||||||
|
...(jsonRecordValue(requestedSessionRef.metadata) ?? {}),
|
||||||
|
...(jsonRecordValue(existing.metadata) ?? {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDurableSessionBoundary(value: string | undefined, durable: string, sessionId: string, field: string): void {
|
||||||
|
if (value === undefined || value === durable) return;
|
||||||
|
throw durableSessionBoundaryError(sessionId, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
function durableSessionBoundaryError(sessionId: string, field: string): AgentRunError {
|
||||||
|
return new AgentRunError("tenant-policy-denied", "sessionRef cannot be reused across tenant or project boundary", {
|
||||||
|
httpStatus: 403,
|
||||||
|
details: { sessionId, field, valuesPrinted: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function submitQueueTask(args: ParsedArgs): Promise<JsonValue> {
|
async function submitQueueTask(args: ParsedArgs): Promise<JsonValue> {
|
||||||
const aipod = optionalFlag(args, "aipod") ?? optionalFlag(args, "aipod-spec");
|
const aipod = optionalFlag(args, "aipod") ?? optionalFlag(args, "aipod-spec");
|
||||||
if (aipod) return submitQueueTaskWithAipod(args, aipod);
|
if (aipod) return submitQueueTaskWithAipod(args, aipod);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { startManagerServer } from "../../mgr/server.js";
|
|||||||
import { MemoryAgentRunStore } from "../../mgr/store.js";
|
import { MemoryAgentRunStore } from "../../mgr/store.js";
|
||||||
import type { JsonRecord } from "../../common/types.js";
|
import type { JsonRecord } from "../../common/types.js";
|
||||||
import { validateAipodSpec } from "../../common/aipod-specs.js";
|
import { validateAipodSpec } from "../../common/aipod-specs.js";
|
||||||
import { validatePrimaryWorkspaceContract, validateResourceBundleRef } from "../../common/validation.js";
|
import { validateCreateCommand, validateCreateRun, validatePrimaryWorkspaceContract, validateResourceBundleRef } from "../../common/validation.js";
|
||||||
import { classifyGitStderr, resolveGitBundleFetchSource } from "../../runner/resource-bundle.js";
|
import { classifyGitStderr, resolveGitBundleFetchSource } from "../../runner/resource-bundle.js";
|
||||||
import { assertNoSecretLeak, loadArtificerImageRef, type SelfTestCase } from "../harness.js";
|
import { assertNoSecretLeak, loadArtificerImageRef, type SelfTestCase } from "../harness.js";
|
||||||
|
|
||||||
@@ -17,12 +17,18 @@ const selfTest: SelfTestCase = async (context) => {
|
|||||||
const artificerSpecFile = path.join(aipodSpecDir, "artificer.yaml");
|
const artificerSpecFile = path.join(aipodSpecDir, "artificer.yaml");
|
||||||
await mkdir(aipodSpecDir, { recursive: true });
|
await mkdir(aipodSpecDir, { recursive: true });
|
||||||
await copyFile(path.join(context.root, "config", "aipods", "artificer.yaml"), artificerSpecFile);
|
await copyFile(path.join(context.root, "config", "aipods", "artificer.yaml"), artificerSpecFile);
|
||||||
|
const store = new MemoryAgentRunStore();
|
||||||
const server = await startManagerServer({
|
const server = await startManagerServer({
|
||||||
port: 0,
|
port: 0,
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
sourceCommit: "self-test",
|
sourceCommit: "self-test",
|
||||||
store: new MemoryAgentRunStore(),
|
store,
|
||||||
aipodSpecDir,
|
aipodSpecDir,
|
||||||
|
sessionPvcOptions: {
|
||||||
|
kubectlHandler: ({ args }) => args[0] === "get"
|
||||||
|
? { stdout: JSON.stringify({ status: { phase: "Bound", capacity: { storage: "1Gi" } } }), stderr: "", exitCode: 0 }
|
||||||
|
: { stdout: "{}", stderr: "", exitCode: 0 },
|
||||||
|
},
|
||||||
runnerJobDefaults: {
|
runnerJobDefaults: {
|
||||||
namespace: "agentrun-v02",
|
namespace: "agentrun-v02",
|
||||||
managerUrl: "http://agentrun-mgr.agentrun-v02.svc.cluster.local:8080",
|
managerUrl: "http://agentrun-mgr.agentrun-v02.svc.cluster.local:8080",
|
||||||
@@ -221,6 +227,52 @@ const selfTest: SelfTestCase = async (context) => {
|
|||||||
assert.equal((sendPlanData.modelResolution as JsonRecord).reasoningEffort, "medium");
|
assert.equal((sendPlanData.modelResolution as JsonRecord).reasoningEffort, "medium");
|
||||||
assert.equal((((sendPlanData.modelResolution as JsonRecord).providerCredential as JsonRecord).secretRef as JsonRecord).name, "agentrun-v02-provider-gpt-pika");
|
assert.equal((((sendPlanData.modelResolution as JsonRecord).providerCredential as JsonRecord).secretRef as JsonRecord).name, "agentrun-v02-provider-gpt-pika");
|
||||||
|
|
||||||
|
const cancelledSessionId = "sess_artificer_cancelled_cli_selftest";
|
||||||
|
const cancelledConversationId = "conv_artificer_cancelled_cli_selftest";
|
||||||
|
const cancelledThreadId = "thread_artificer_cancelled_cli_selftest";
|
||||||
|
const cancelledRun = store.createRun(validateCreateRun({
|
||||||
|
tenantId: "unidesk",
|
||||||
|
projectId: "pikainc/selfmedia",
|
||||||
|
providerId: task.providerId,
|
||||||
|
backendProfile: task.backendProfile,
|
||||||
|
workspaceRef: task.workspaceRef,
|
||||||
|
sessionRef: {
|
||||||
|
sessionId: cancelledSessionId,
|
||||||
|
conversationId: cancelledConversationId,
|
||||||
|
threadId: cancelledThreadId,
|
||||||
|
expiresAt: "2030-01-01T00:00:00.000Z",
|
||||||
|
metadata: { lastTurnId: "turn_cancelled_cli_selftest", durable: true },
|
||||||
|
},
|
||||||
|
executionPolicy: task.executionPolicy,
|
||||||
|
resourceBundleRef: task.resourceBundleRef,
|
||||||
|
traceSink: { kind: "queue", taskId: "qt_cancelled_cli_selftest", aipod: "Artificer" },
|
||||||
|
}));
|
||||||
|
const cancelledCommand = store.createCommand(cancelledRun.id, validateCreateCommand({ type: "turn", payload: { prompt: "cancelled artificer turn" } }));
|
||||||
|
store.finishCommand(cancelledCommand.id, { terminalStatus: "cancelled", failureKind: "cancelled", failureMessage: "self-test cancellation" });
|
||||||
|
store.finishRun(cancelledRun.id, { terminalStatus: "cancelled", failureKind: "cancelled", failureMessage: "self-test cancellation" });
|
||||||
|
assert.equal(store.getSession(cancelledSessionId)?.lastRunId, cancelledRun.id);
|
||||||
|
|
||||||
|
const conflict = await runCliFailureJson(context, server.baseUrl, ["sessions", "send", cancelledSessionId, "--aipod", "Artificer", "--project-id", "pikasTech/unidesk", "--prompt", "must remain fail closed", "--no-runner-job"]);
|
||||||
|
assert.equal(conflict.ok, false);
|
||||||
|
assert.match(JSON.stringify(conflict), /tenant-policy-denied/u);
|
||||||
|
assert.equal(store.getSession(cancelledSessionId)?.lastRunId, cancelledRun.id, "boundary conflict must not mutate the cancelled session");
|
||||||
|
|
||||||
|
const continued = await runCliJson(context, server.baseUrl, ["sessions", "send", cancelledSessionId, "--aipod", "Artificer", "--prompt", "continue cancelled selfmedia task", "--no-runner-job"]);
|
||||||
|
assert.equal(continued.ok, true);
|
||||||
|
const continuedData = continued.data as JsonRecord;
|
||||||
|
assert.equal(continuedData.action, "session-send");
|
||||||
|
assert.equal(continuedData.decision, "turn");
|
||||||
|
const continuedSession = store.getSession(cancelledSessionId);
|
||||||
|
assert.notEqual(continuedSession?.lastRunId, cancelledRun.id);
|
||||||
|
const continuedRun = store.getRun(String(continuedSession?.lastRunId));
|
||||||
|
assert.equal(continuedRun.tenantId, "unidesk");
|
||||||
|
assert.equal(continuedRun.projectId, "pikainc/selfmedia");
|
||||||
|
assert.equal(continuedRun.sessionRef?.sessionId, cancelledSessionId);
|
||||||
|
assert.equal(continuedRun.sessionRef?.conversationId, cancelledConversationId);
|
||||||
|
assert.equal(continuedRun.sessionRef?.threadId, cancelledThreadId);
|
||||||
|
assert.equal(continuedRun.sessionRef?.metadata?.lastTurnId, "turn_cancelled_cli_selftest");
|
||||||
|
assert.equal((continuedRun.traceSink as JsonRecord).aipod, "Artificer");
|
||||||
|
|
||||||
const help = await runCliJson(context, server.baseUrl, ["help"]);
|
const help = await runCliJson(context, server.baseUrl, ["help"]);
|
||||||
const commands = ((help.data as JsonRecord).commands as string[]) ?? [];
|
const commands = ((help.data as JsonRecord).commands as string[]) ?? [];
|
||||||
assert.equal(commands.some((item) => item.includes("aipod-specs render <name>")), true);
|
assert.equal(commands.some((item) => item.includes("aipod-specs render <name>")), true);
|
||||||
@@ -240,7 +292,7 @@ const selfTest: SelfTestCase = async (context) => {
|
|||||||
assert.equal((storedTask.payload as JsonRecord).model, "gpt-5.6-sol");
|
assert.equal((storedTask.payload as JsonRecord).model, "gpt-5.6-sol");
|
||||||
assert.equal((storedTask.payload as JsonRecord).reasoningEffort, "medium");
|
assert.equal((storedTask.payload as JsonRecord).reasoningEffort, "medium");
|
||||||
assert.deepEqual((storedTask.payload as JsonRecord).modelConfig, taskPayload.modelConfig);
|
assert.deepEqual((storedTask.payload as JsonRecord).modelConfig, taskPayload.modelConfig);
|
||||||
return { name: "aipod-spec", tests: ["aipod-spec-yaml-parser-runtime-compatible", "aipod-spec-artificer-image-ref-render", "aipod-spec-artificer-v02-runtime-authority", "aipod-spec-gpt-pika-secret-namespace", "aipod-spec-model-defaults", "aipod-spec-model-reasoning-overrides", "aipod-spec-model-override-keeps-provider", "aipod-spec-control-override-fail-closed", "aipod-spec-payload-control-bypass-fail-closed", "aipod-spec-missing-gpt-pika-credential", "aipod-spec-durable-task-model-snapshot", "aipod-spec-primary-workspace-contract", "aipod-spec-artificer-github-url-render", "aipod-spec-artificer-github-ssh-required-keys", "aipod-spec-git-mirror-url", "git-fetch-stderr-classification", "resource-bundle-source-authority-validation", "resource-bundle-target-conflict-validation", "queue-submit-aipod-dry-run", "session-send-aipod-model-dry-run", "aipod-cli-help"] };
|
return { name: "aipod-spec", tests: ["aipod-spec-yaml-parser-runtime-compatible", "aipod-spec-artificer-image-ref-render", "aipod-spec-artificer-v02-runtime-authority", "aipod-spec-gpt-pika-secret-namespace", "aipod-spec-model-defaults", "aipod-spec-model-reasoning-overrides", "aipod-spec-model-override-keeps-provider", "aipod-spec-control-override-fail-closed", "aipod-spec-payload-control-bypass-fail-closed", "aipod-spec-missing-gpt-pika-credential", "aipod-spec-durable-task-model-snapshot", "aipod-spec-primary-workspace-contract", "aipod-spec-artificer-github-url-render", "aipod-spec-artificer-github-ssh-required-keys", "aipod-spec-git-mirror-url", "git-fetch-stderr-classification", "resource-bundle-source-authority-validation", "resource-bundle-target-conflict-validation", "queue-submit-aipod-dry-run", "session-send-aipod-model-dry-run", "session-send-aipod-cancelled-durable-identity", "session-send-aipod-boundary-conflict-fail-closed", "aipod-cli-help"] };
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
||||||
}
|
}
|
||||||
@@ -266,6 +318,13 @@ async function runCliJson(context: { root: string }, managerUrl: string, args: s
|
|||||||
return JSON.parse(stdout) as JsonRecord;
|
return JSON.parse(stdout) as JsonRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runCliFailureJson(context: { root: string }, managerUrl: string, args: string[]): Promise<JsonRecord> {
|
||||||
|
const proc = spawn(process.execPath, [`${context.root}/scripts/agentrun-cli.ts`, "--manager-url", managerUrl, ...args], { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
const [stdout, stderr, code] = await Promise.all([readStream(proc.stdout), readStream(proc.stderr), new Promise<number | null>((resolve) => proc.on("close", resolve))]);
|
||||||
|
assert.notEqual(code, 0, stderr || stdout);
|
||||||
|
return JSON.parse(stdout) as JsonRecord;
|
||||||
|
}
|
||||||
|
|
||||||
async function readStream(stream: NodeJS.ReadableStream): Promise<string> {
|
async function readStream(stream: NodeJS.ReadableStream): Promise<string> {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
||||||
|
|||||||
Reference in New Issue
Block a user