Reject queued input for terminal Goal runners

This commit is contained in:
mateaix 2026-09-15 08:18:14 +08:00
parent 1f414a629d
commit 39f75ba255
6 changed files with 45 additions and 6 deletions

View File

@ -150,13 +150,15 @@ public class GoalSegmentRunner {
return new SegmentOutcome.Cancelled("paused");
boolean required=currentGoal!=null && currentGoal.isJsonAcceptanceRequired();
boolean selected=queued.selectedGoalId()!=null && queued.selectedGoalId()>0;
boolean unavailable=currentGoal==null
|| currentGoal.getStatus()!=vip.mate.goal.model.GoalStatus.ACTIVE;
boolean ambiguousLegacy=queued.selectedGoalId()==null && !required && approvalRuns!=null
&& approvalRuns.hasManagedGoalHistory(convId,String.valueOf(goal.getAgentId()));
if (required || selected || ambiguousLegacy) {
if (required || selected || ambiguousLegacy || unavailable) {
var queuedOrigin=ChatOrigin.web(convId,queued.createdBy(),goal.getWorkspaceId(),
null,null,queued.requesterUserId()).withAgent(goal.getAgentId())
.withSelectedGoalId(queued.selectedGoalId());
if (!required || currentGoal.getStatus()!=vip.mate.goal.model.GoalStatus.ACTIVE
if (unavailable || !required
|| !Objects.equals(queued.selectedGoalId(),goal.getId())
|| approvalRuns==null || !approvalRuns.queuedSelectionStillCurrent(queuedOrigin)) {
persistQueuedInput(convId,queued);

View File

@ -18,7 +18,7 @@ Prefix: `/api/v1/goals/{goalId}/json-acceptance`. An enabled account with conver
Publication requires an active or paused goal and a slot referenced by a current requirement. Content must be a strict JSON object: duplicate keys, trailing documents, nesting beyond 32 levels and UTF-8 content over 1 MiB are rejected. Each goal can retain at most 32 versions; the limit rejects new publication instead of overwriting history. Each version expires after 24 hours. Republishing identical bytes still creates a new version. Reload after a generation conflict rather than automatically overwriting another publication. Retries can reuse a suitable current version and refresh its check binding; reaching the quota still permits checking and completing with that version. If the version is expired or its content must change and all 32 versions are used, further publication remains unavailable.
Managed bodies live independently in the database. Ordinary workspace files, cache paths and hashes in text are not substitutes. No publication API edits historical bodies; bodies and pointers commit together. SHA-256 identifies content and supports integrity checks; it does not isolate an attacker with database credentials or host privileges. The database and service host are trusted foundations of this limited protocol. The JSON service contract has been exercised on H2, MySQL 8.0.46 and PostgreSQL 16.14. MySQL and PostgreSQL each passed 72 JSON protocol cases with V201 on cycle062 and 27 opt-in HTTP approval/authentication and scheduled-queue cases on cycle068, including foreign-Goal and legacy-unknown rejection before any model call and paused queue delivery after resume. The MySQL run isolated an existing V192 migration failure using a test-only migration copy; PostgreSQL used the original Kingbase migration tree while skipping an unrelated bundled-skill import failure. These are protocol tests, not confirmation that an unmodified full installation succeeds. The proprietary Kingbase engine has not been tested.
Managed bodies live independently in the database. Ordinary workspace files, cache paths and hashes in text are not substitutes. No publication API edits historical bodies; bodies and pointers commit together. SHA-256 identifies content and supports integrity checks; it does not isolate an attacker with database credentials or host privileges. The database and service host are trusted foundations of this limited protocol. The JSON service contract has been exercised on H2, MySQL 8.0.46 and PostgreSQL 16.14. MySQL and PostgreSQL each passed 72 JSON protocol cases with V201 on cycle062 and 29 opt-in HTTP approval/authentication and scheduled-queue cases on cycle069, including foreign-Goal, legacy-unknown and terminal-Goal rejection before any model call and paused queue delivery after resume. The MySQL run isolated an existing V192 migration failure using a test-only migration copy; PostgreSQL used the original Kingbase migration tree while skipping an unrelated bundled-skill import failure. These are protocol tests, not confirmation that an unmodified full installation succeeds. The proprietary Kingbase engine has not been tested.
## Agent publication
@ -59,7 +59,7 @@ Built-in shell/code execution is not OS-isolated from the service host. Selectin
Recovery attempts receive guidance to inspect existing evidence before repeating work. If the first recovered segment is deferred before execution, its recovery context is retained for the next claim. Ordinary continuation after an executed segment does not become a new recovery.
From V199, queued Web input stores the authenticated account ID at enqueue time, and ordinary Web replay carries the conversation workspace. V201 also stores the selected managed Goal ID at enqueue time. Before a selected queued turn starts, the server rechecks its account and Goal. If either is no longer current, it saves the user text and asks for a fresh request without starting the agent. An old queue row without a selection snapshot is handled the same way when its conversation has managed Goal history. The chat interface clears that queued item and prompts the user to resend; later queued items continue. A persistent Goal worker also checks the queued selection and original account before running it. It saves a mismatched, revoked or legacy-unknown input from a conversation with managed Goal history as conversation text with a durable assistant notice, then continues to later queued items. An explicitly unselected queue item still follows the unselected path for an unmanaged Goal. A paused Goal retains its claimed input for processing after resume. Managed operations still recheck the account, ownership and current requirements. Legacy queue items do not gain an asserted identity from a username; users must resend an authenticated request for managed JSON operations. Persistent Goal workers also retain their attempt-owner validation; queue validation does not replace the lease check.
From V199, queued Web input stores the authenticated account ID at enqueue time, and ordinary Web replay carries the conversation workspace. V201 also stores the selected managed Goal ID at enqueue time. Before a selected queued turn starts, the server rechecks its account and Goal. If either is no longer current, it saves the user text and asks for a fresh request without starting the agent. An old queue row without a selection snapshot is handled the same way when its conversation has managed Goal history. The chat interface clears that queued item and prompts the user to resend; later queued items continue. A persistent Goal worker also checks the queued selection and original account before running it. It saves a mismatched, revoked or legacy-unknown input from a conversation with managed Goal history as conversation text with a durable assistant notice, then continues to later queued items. An explicitly unselected queue item still follows the unselected path for an active unmanaged Goal. A terminal Goal cannot run any queued input; its text and notice are saved instead. A paused Goal retains its claimed input for processing after resume. Managed operations still recheck the account, ownership and current requirements. Legacy queue items do not gain an asserted identity from a username; users must resend an authenticated request for managed JSON operations. Persistent Goal workers also retain their attempt-owner validation; queue validation does not replace the lease check.
Approval replay restores the persisted runtime identity; approval does not renew an expired attempt lease or override account revocation. Legacy snapshots without an authenticated account ID cannot gain managed JSON access from a display username alone.

View File

@ -18,7 +18,7 @@
仅当前要求引用的槽可发布Goal 必须 active 或 paused。正文必须是严格 JSON 对象,拒绝重复键、尾随文档、超过 32 层的嵌套及超过 1 MiB 的 UTF-8 内容。每个 Goal 最多保存 32 个版本;达到配额拒绝继续发布,不覆盖旧版本。每版有效期 24 小时,重复发布同样正文也产生新版本。客户端遇到 generation 冲突应重新读取不自动覆盖他人发布。重试可以复用适用的当前版本并更新检查绑定达到配额后仍可检查当前版本并在合格时完成。如果版本已过期或正文必须修改且32个版本均已使用则不能继续发布。
这些版本独立存储在数据库,不能用普通工作区文件、缓存路径或文字中的 hash 替代。发布接口不支持更新历史正文所有版本与槽指针同事务保存。SHA-256 用于标识及完整性核对不能隔离拥有数据库凭据或宿主权限的攻击者数据库和服务宿主是此有限协议的可信基础。MySQL与PostgreSQL在带V201的cycle062源码上各通过72项JSON协议案例在cycle068各通过27项显式启用的HTTP审批/认证及后台队列案例包含跨Goal或旧未知选择错领后模型调用0、暂停恢复后才执行的案例。MySQL 使用仅修正既有 V192 失败的临时迁移副本PostgreSQL 使用原始 Kingbase 迁移树,跳过无关的内置技能导入失败。这是协议验证,不能代表未修改的完整安装成功;尚未实测 Kingbase 专有引擎。
这些版本独立存储在数据库,不能用普通工作区文件、缓存路径或文字中的 hash 替代。发布接口不支持更新历史正文所有版本与槽指针同事务保存。SHA-256 用于标识及完整性核对不能隔离拥有数据库凭据或宿主权限的攻击者数据库和服务宿主是此有限协议的可信基础。MySQL与PostgreSQL在带V201的cycle062源码上各通过72项JSON协议案例在cycle069各通过29项显式启用的HTTP审批/认证及后台队列案例包含跨Goal、旧未知选择或终态Goal错领后模型调用0、暂停恢复后才执行的案例。MySQL 使用仅修正既有 V192 失败的临时迁移副本PostgreSQL 使用原始 Kingbase 迁移树,跳过无关的内置技能导入失败。这是协议验证,不能代表未修改的完整安装成功;尚未实测 Kingbase 专有引擎。
## 代理发布
@ -57,7 +57,7 @@ V198 同样以绝对时间保存调度租约截止。升级时旧租约失效,
内置 shell/code 执行没有与服务宿主做操作系统隔离。选择 JSON 验收不会把这些工具变成沙箱;此协议不能抵抗能访问数据库凭据或文件的宿主代码,环境变量名称过滤和工作区路径检查也不能替代隔离。租约截止从绝对时刻计算,覆盖夏令时回拨;调度显示字段仍使用本地时间戳。
Web排队消息从V199起保存入队时已认证账户的内部ID普通Web续跑同时携带当前会话工作区V201还在入队时保存选定的受管Goal ID。选定Goal的排队消息在开始执行前复查账户与Goal若已失效只保存用户文字并提示重新发送不启动Agent。升级前没有选择快照的队列行若会话有受管Goal历史也按此方式处理。聊天页面会移除这条排队状态并提示重新发送后续排队消息继续处理。持久Goal工作器也在执行排队消息前校验原选定Goal与账户不匹配、已撤权或有受管历史的旧未知选择时保存用户正文和持久的助手告知再继续后续队列。明确未选定的队列项仍可按未受管Goal旧路径运行。Goal暂停时保留已领取的队列行恢复后再处理。受管工具执行时仍重新校验账户、归属和当前要求。旧队列项不按用户名补造身份不能用于受管JSON操作需要用户重新发送已认证请求。持久Goal工作器仍校验原有attempt owner及租约队列校验不能代替它。
Web排队消息从V199起保存入队时已认证账户的内部ID普通Web续跑同时携带当前会话工作区V201还在入队时保存选定的受管Goal ID。选定Goal的排队消息在开始执行前复查账户与Goal若已失效只保存用户文字并提示重新发送不启动Agent。升级前没有选择快照的队列行若会话有受管Goal历史也按此方式处理。聊天页面会移除这条排队状态并提示重新发送后续排队消息继续处理。持久Goal工作器也在执行排队消息前校验原选定Goal与账户不匹配、已撤权或有受管历史的旧未知选择时保存用户正文和持久的助手告知再继续后续队列。明确未选定的队列项仍可按ACTIVE未受管Goal旧路径运行终态Goal不执行任何排队消息而是保存正文和告知。Goal暂停时保留已领取的队列行恢复后再处理。受管工具执行时仍重新校验账户、归属和当前要求。旧队列项不按用户名补造身份不能用于受管JSON操作需要用户重新发送已认证请求。持久Goal工作器仍校验原有attempt owner及租约队列校验不能代替它。
恢复执行会收到先核实已有证据、不要重放未知副作用的提示。首次恢复执行若在实际运行前延期,下一次领取仍保留恢复关联;已经执行过后的普通续跑不会因此变成新恢复。

View File

@ -20,6 +20,7 @@ class GoalJsonExternalApprovalIntegrationTest extends GoalJsonHttpRuntimeIntegra
"false,scheduled-queued-legacy,true", "true,scheduled-queued-legacy,true",
"false,scheduled-queued-legacy-new-goal,true", "true,scheduled-queued-legacy-new-goal,true",
"false,scheduled-queued-unselected,true", "true,scheduled-queued-unselected,true",
"false,scheduled-queued-terminal-unselected,true", "true,scheduled-queued-terminal-unselected,true",
"false,scheduled-queued-paused,true", "true,scheduled-queued-paused,true",
"false,terminal-approval,true", "true,terminal-approval,true",
"false,legacy-terminal-approval,true", "true,legacy-terminal-approval,true",

View File

@ -82,6 +82,7 @@ class GoalJsonHttpRuntimeIntegrationTest {
"false,scheduled-queued-legacy,true", "true,scheduled-queued-legacy,true",
"false,scheduled-queued-legacy-new-goal,true", "true,scheduled-queued-legacy-new-goal,true",
"false,scheduled-queued-unselected,true", "true,scheduled-queued-unselected,true",
"false,scheduled-queued-terminal-unselected,true", "true,scheduled-queued-terminal-unselected,true",
"false,scheduled-queued-paused,true", "true,scheduled-queued-paused,true", "false,recovered,true", "true,recovered,true",
"false,scheduled,false", "true,scheduled,false", "false,recovered,false", "true,recovered,false",
"false,queued,true", "false,reuse,true", "true,reuse,true", "false,recheck,true", "true,recheck,true",
@ -596,6 +597,25 @@ class GoalJsonHttpRuntimeIntegrationTest {
runner.cancel(goal.getId());
}
} else if (scheduled) {
if (entry.equals("scheduled-queued-terminal-unselected")) {
var queuedInput = new vip.mate.channel.web.ConversationInputQueueStore(jdbc, json).enqueue(
conversation, agentId, username, "Unselected input after Goal ended", List.of(),
userId, 0L, java.time.LocalDateTime.now());
goals.abandon(goal.getId(), username);
SegmentOutcome outcome = runner.run(run, message, false);
assertInstanceOf(SegmentOutcome.Continue.class, outcome);
assertEquals(0, calls.get(), "Terminal Goal must reject queued input before model execution");
assertEquals("consumed", jdbc.queryForObject(
"SELECT state FROM mate_conversation_input_queue WHERE id=?", String.class, queuedInput.id()));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_message WHERE conversation_id=? AND role='user' AND content=?",
Integer.class, conversation, "Unselected input after Goal ended"));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_message WHERE conversation_id=? AND role='assistant' AND content LIKE ?",
Integer.class, conversation, "%was not run because its selected Goal%"));
assertTrue(coordinator.settle(run, outcome, java.time.LocalDateTime.now()));
return;
}
if (entry.equals("scheduled-queued-legacy-new-goal")) {
goals.abandon(goal.getId(), username);
assertTrue(coordinator.settle(run, new SegmentOutcome.Cancelled("replaced"),

View File

@ -42,6 +42,7 @@ class GoalSegmentRunnerTest {
@BeforeEach void setup() {
goal.setId(1L);goal.setConversationId("conv");goal.setAgentId(2L);goal.setWorkspaceId(3L);goal.setCreatedBy("alice");
goal.setStatus(vip.mate.goal.model.GoalStatus.ACTIVE);
ConversationEntity conv=new ConversationEntity();
conv.setConversationId("conv");conv.setAgentId(2L);conv.setWorkspaceId(3L);conv.setUsername("alice");
when(conversations.findByConversationId("conv")).thenReturn(conv);
@ -262,6 +263,21 @@ class GoalSegmentRunnerTest {
verify(inputQueue).consume(eq(106L),anyString(),any());
}
@Test void terminalUnmanagedGoalDoesNotRunExplicitlyUnselectedInput() {
goal.setStatus(vip.mate.goal.model.GoalStatus.ABANDONED);
var now=LocalDateTime.now();
durableInputs.add(new ConversationInputQueueStore.QueuedInput(107L,"conv",2L,"alice",
"instruction after Goal ended",List.of(),"queued",null,null,null,
now,now,42L,0L));
when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any()))
.thenReturn(Flux.just(new AgentService.StreamDelta("incorrect execution",null)));
runner.run(goal,"continue",false);
verify(agents,never()).chatStructuredStream(any(),any(),any(),any(),any(),any());
verify(inputQueue).consume(eq(107L),anyString(),any());
}
@Test void workerCancellationPersistsPartialEvidenceAndReleasesAdmission() throws Exception {
var subscribed=new java.util.concurrent.CountDownLatch(1);
var toolCancelled=new java.util.concurrent.atomic.AtomicBoolean();