fix: serialize codex submit bursts
This commit is contained in:
@@ -31,7 +31,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI
|
||||
- `artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service` 管理 D601 host-managed CNCF Distribution registry 的声明、安装、只读检查和 pull-only artifact CD。该 registry 固定为 D601 loopback `127.0.0.1:5000`,由 systemd + Docker Compose 管理,位于 native k3s 故障域外;`deploy-backend-core` 和 `deploy-service` 只拉取 CI 已发布的 commit-pinned 镜像、retag/recreate 或导入 native k3s,并做 live commit 验证,不构建 runtime source。长期规则见 `docs/reference/artifact-registry.md`。
|
||||
- `ci install|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs` 管理 D601 原生 k3s 上的 Tekton CI。`run` 手动创建每 commit 检查和 Code Queue 只读性能门禁;`publish-backend-core` 与 `publish-user-service` 从 pushed Git commit 构建并发布 `127.0.0.1:5000/unidesk/<service>:<commit>` commit-pinned artifacts,输出 `artifactSummary`(含 `serviceId`、`sourceCommit`、`sourceRepo`、`dockerfile`、`imageRef`、`tag`、`digest`、`digestRef`),但不部署生产;`run-dev-e2e` 的 Git 控制 runner、短 launcher、host fetch 边界、临时 smoke namespace 和 no-CD 规则只在 `docs/reference/dev-ci-runner.md` 定义;Tekton CI 通用规则见 `docs/reference/ci.md`。
|
||||
- `codex deploy <commitId>` 是旧 Code Queue 兼容部署入口,已禁用以防止维护通道直连 D601 部署 Code Queue;当前 dev 自动化只做 `ci run-dev-e2e` smoke,不提供 Code Queue CD,详细规则见 `docs/reference/codex-deploy.md`。
|
||||
- `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--reasoning-effort effort] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]` 通过 backend-core 私有代理向稳定 `code-queue` 用户服务路径提交任务;prompt 必须且只能来自位置参数、文件或 stdin 之一,`--dry-run` 只返回结构化请求且不实际入队。提交确认和 dry-run 必须返回完整 prompt、字符数和 `truncated=false`,不能套用任务详情的预览截断策略,否则长任务 prompt 无法被人工验收。backend-core 默认把提交、队列 CRUD、已读状态、历史摘要和轻量 Trace 读取分流到主 server `code-queue-mgr`,由它写入主 PostgreSQL;D601 scheduler 只轮询并执行已入库任务。
|
||||
- `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--reasoning-effort effort] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]` 通过 backend-core 私有代理向稳定 `code-queue` 用户服务路径提交任务;prompt 必须且只能来自位置参数、文件或 stdin 之一,`--dry-run` 只返回结构化请求且不实际入队。提交确认和 dry-run 必须返回完整 prompt、字符数和 `truncated=false`,不能套用任务详情的预览截断策略,否则长任务 prompt 无法被人工验收。真实提交会经过本机本地串行化保护和短节流,避免同一指挥端并发 submit 把低内存主机或 `code-queue-mgr` 控制面打抖;返回值会附带 `submitConcurrencyGuard` 说明本次提交的锁与等待信息。backend-core 默认把提交、队列 CRUD、已读状态、历史摘要和轻量 Trace 读取分流到主 server `code-queue-mgr`,由它写入主 PostgreSQL;D601 scheduler 只轮询并执行已入库任务。
|
||||
- `codex task <taskId>` 通过 Code Queue 私有代理按任务 ID 查询结构化执行摘要;默认只返回有界 prompt/response 预览、执行 Provider、工作目录、最后 assistant message、最近工具调用摘要、attempt、judge、错误、耗时和 trace 翻页提示,适合在新队列任务中引用历史 session 且避免噪声爆炸。该摘要读取默认由主 server `code-queue-mgr` 从 PostgreSQL 返回,不依赖 D601 `code-queue-read` Service 可用。
|
||||
- `codex tasks [--queue id] [--limit N] [--unread-only]` 通过同一私有代理输出一个只读聚合视图,按 `running`、`completedUnread`、`recentCompleted` 三个 section 汇总当前需要盯的任务;每个条目都带 `taskId`、`queueId`、`status`、`currentAttempt`、`updatedAt`、`finishedAt`、`unread`/`unreadTerminal`、`lastAssistantMessage` 摘要和可直接复制的 `commands.show` / `commands.trace`。`--queue` 限定单个队列,`--limit` 控制各 section 的最大条数,`--unread-only` 只保留未读终态和正在运行的任务。
|
||||
- `codex task <taskId> --trace --tail|--from-start|--after-seq N|--before-seq N --limit N` 按页拉取 Code Queue 的逻辑 trace;响应会返回 `nextAfterSeq`、`previousBeforeSeq`、`hasMore`、`hasBefore` 和下一页/上一页命令,默认 `--trace` 取最新一页,需要完整 prompt/最后 response 时加 `--full`。
|
||||
|
||||
@@ -37,6 +37,8 @@ Every Code Queue task must have a narrow ownership boundary.
|
||||
|
||||
Prompts for production-adjacent work must explicitly forbid heavy local checks on the master server when those checks are known to risk OOM, and must tell the worker which validation belongs in D601 CI, dev env, or a target service container.
|
||||
|
||||
When one supervisor machine is creating many Code Queue tasks in a burst, submit calls should default to serial or near-serial behavior. A short local lock or delay is acceptable if it prevents the control plane from being flooded faster than it can acknowledge tasks, especially on low-memory hosts. The goal is to keep task creation observable and stable, not to maximize raw enqueue throughput.
|
||||
|
||||
## Monitoring
|
||||
|
||||
The supervisor must monitor Code Queue with task-level and queue-level evidence, not with a single status field.
|
||||
@@ -89,6 +91,7 @@ Examples of infrastructure defects include:
|
||||
- CLI observability that cannot show running, recently completed, or unread terminal tasks;
|
||||
- a proxy path that differs between WebUI and CLI;
|
||||
- a deploy job that reports failure even though the service API is healthy.
|
||||
- a supervisor-side submit burst that can saturate the Code Queue manager or low-memory host before the queue has a chance to acknowledge tasks.
|
||||
|
||||
These defects should be assigned to infrastructure queues with prompts that include the concrete observed failure, the expected long-term contract, and the recovery action required for the original delivery task.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { type UniDeskConfig } from "./config";
|
||||
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
|
||||
import { coreInternalFetch } from "./microservices";
|
||||
|
||||
const defaultToolLimit = 8;
|
||||
@@ -9,6 +9,10 @@ const defaultOutputLimit = 20;
|
||||
const defaultTextPreviewChars = 12_000;
|
||||
const defaultTasksLimit = 20;
|
||||
const maxTasksLimit = 100;
|
||||
const submitLockWaitMs = 60_000;
|
||||
const submitLockPollMs = 250;
|
||||
const submitLockStaleMs = 120_000;
|
||||
const submitThrottleMs = nonNegativeIntegerEnv("UNIDESK_CODEX_SUBMIT_THROTTLE_MS", 2000);
|
||||
|
||||
interface CodexTaskOptions {
|
||||
trace: boolean;
|
||||
@@ -100,6 +104,84 @@ function codeQueueProxyPath(path: string): string {
|
||||
return `${codeQueueProxyPrefix}${path}`;
|
||||
}
|
||||
|
||||
function nonNegativeIntegerEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim().length === 0) return fallback;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function sleepSync(ms: number): void {
|
||||
if (ms <= 0) return;
|
||||
const buffer = new SharedArrayBuffer(4);
|
||||
Atomics.wait(new Int32Array(buffer), 0, 0, ms);
|
||||
}
|
||||
|
||||
function acquireSubmitLock(): { lockPath: string; acquiredAfterMs: number; release: () => void } {
|
||||
const lockRoot = rootPath(".state", "locks");
|
||||
const lockPath = rootPath(".state", "locks", "codex-submit.lock.d");
|
||||
mkdirSync(lockRoot, { recursive: true });
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt <= submitLockWaitMs) {
|
||||
try {
|
||||
mkdirSync(lockPath);
|
||||
writeFileSync(rootPath(".state", "locks", "codex-submit.lock.d", "owner.json"), `${JSON.stringify({
|
||||
pid: process.pid,
|
||||
createdAt: new Date().toISOString(),
|
||||
repoRoot,
|
||||
}, null, 2)}\n`, "utf8");
|
||||
let released = false;
|
||||
return {
|
||||
lockPath,
|
||||
acquiredAfterMs: Date.now() - startedAt,
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
rmSync(lockPath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string }).code;
|
||||
if (code !== "EEXIST") throw error;
|
||||
let stale = false;
|
||||
try {
|
||||
stale = Date.now() - statSync(lockPath).mtimeMs > submitLockStaleMs;
|
||||
} catch {
|
||||
stale = true;
|
||||
}
|
||||
if (stale) {
|
||||
rmSync(lockPath, { recursive: true, force: true });
|
||||
continue;
|
||||
}
|
||||
sleepSync(submitLockPollMs);
|
||||
}
|
||||
}
|
||||
throw new Error(`codex submit lock timed out after ${submitLockWaitMs}ms: ${lockPath}`);
|
||||
}
|
||||
|
||||
function runWithSubmitLock<T>(operation: () => T): { result: T; lock: Record<string, unknown> } {
|
||||
const lock = acquireSubmitLock();
|
||||
const operationStartedAt = Date.now();
|
||||
try {
|
||||
sleepSync(submitThrottleMs);
|
||||
const result = operation();
|
||||
return {
|
||||
result,
|
||||
lock: {
|
||||
path: lock.lockPath,
|
||||
acquiredAfterMs: lock.acquiredAfterMs,
|
||||
heldMs: Date.now() - operationStartedAt,
|
||||
throttleMs: submitThrottleMs,
|
||||
staleMs: submitLockStaleMs,
|
||||
mode: "local-atomic-directory-submit-serialization",
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
function requireTaskId(value: string | undefined, command: string): string {
|
||||
if (value === undefined || value.trim().length === 0) throw new Error(`${command} requires task id`);
|
||||
return value.trim();
|
||||
@@ -1235,11 +1317,13 @@ function codexSubmitTask(args: string[]): unknown {
|
||||
},
|
||||
};
|
||||
}
|
||||
const response = unwrapCodexResponse(coreInternalFetch(codeQueueProxyPath("/api/tasks"), { method: "POST", body: payload }));
|
||||
const locked = runWithSubmitLock(() => unwrapCodexResponse(coreInternalFetch(codeQueueProxyPath("/api/tasks"), { method: "POST", body: payload })));
|
||||
const response = locked.result;
|
||||
return {
|
||||
upstream: response.upstream,
|
||||
tasks: asArray(response.body.tasks).map((task) => compactTaskMutationResponse(task, { fullPrompt: true })),
|
||||
queue: compactQueueMutationSummary(response.body.queue),
|
||||
submitConcurrencyGuard: locked.lock,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user