mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix: address review comments (4 items)
This commit is contained in:
parent
b759eb8719
commit
b321d5792e
@ -243,6 +243,23 @@ public class CronJobService implements ApplicationRunner {
|
||||
* No {@code @TableLogic} on this entity — {@code deleted=0} must be
|
||||
* filtered explicitly.
|
||||
*/
|
||||
/**
|
||||
* Issue #50 review #6 — excluding-self variant for the update path.
|
||||
* Same lookup as {@link #findActiveDuplicate} but skips the row
|
||||
* currently being edited so a no-op save (same name, same agent)
|
||||
* doesn't false-positive as a duplicate.
|
||||
*/
|
||||
private CronJobEntity findActiveDuplicateExcluding(Long workspaceId, Long agentId, String name, Long excludeId) {
|
||||
if (workspaceId == null || agentId == null || name == null) return null;
|
||||
LambdaQueryWrapper<CronJobEntity> q = new LambdaQueryWrapper<CronJobEntity>()
|
||||
.eq(CronJobEntity::getWorkspaceId, workspaceId)
|
||||
.eq(CronJobEntity::getAgentId, agentId)
|
||||
.eq(CronJobEntity::getName, name)
|
||||
.eq(CronJobEntity::getDeleted, 0);
|
||||
if (excludeId != null) q.ne(CronJobEntity::getId, excludeId);
|
||||
return cronJobMapper.selectOne(q);
|
||||
}
|
||||
|
||||
private CronJobEntity findActiveDuplicate(Long workspaceId, Long agentId, String name) {
|
||||
if (workspaceId == null || agentId == null || name == null) return null;
|
||||
return cronJobMapper.selectOne(
|
||||
@ -264,6 +281,22 @@ public class CronJobService implements ApplicationRunner {
|
||||
validateDto(dto);
|
||||
String springCron = toSpringCron(dto.getCronExpression());
|
||||
|
||||
// Issue #50 review #6: excluding-self duplicate check. Without
|
||||
// this, renaming a job onto an existing (workspace, agent, name)
|
||||
// tuple surfaces as a raw DataIntegrityViolationException from
|
||||
// the V69 unique index instead of a controlled validation
|
||||
// error. Try app-level check first; the catch below is the
|
||||
// race-protection net.
|
||||
Long newAgentId = dto.getAgentId();
|
||||
String newName = dto.getName();
|
||||
if (newName != null && newAgentId != null) {
|
||||
CronJobEntity collision = findActiveDuplicateExcluding(workspaceId, newAgentId, newName, id);
|
||||
if (collision != null) {
|
||||
throw new MateClawException("err.cron.duplicate_name",
|
||||
"已存在同名定时任务: name=" + newName + ", agentId=" + newAgentId);
|
||||
}
|
||||
}
|
||||
|
||||
existing.setName(dto.getName());
|
||||
existing.setCronExpression(dto.getCronExpression());
|
||||
existing.setTimezone(dto.getTimezone() != null ? dto.getTimezone() : "Asia/Shanghai");
|
||||
@ -276,7 +309,16 @@ public class CronJobService implements ApplicationRunner {
|
||||
}
|
||||
existing.setNextRunTime(calcNextRunTime(springCron, existing.getTimezone()));
|
||||
|
||||
cronJobMapper.updateById(existing);
|
||||
try {
|
||||
cronJobMapper.updateById(existing);
|
||||
} catch (DuplicateKeyException e) {
|
||||
// Race: another concurrent rename grabbed the natural key
|
||||
// between our app-level check and the UPDATE. Translate to
|
||||
// a clean validation error so the controller can 4xx instead
|
||||
// of a 500 leaking the unique-key constraint name.
|
||||
throw new MateClawException("err.cron.duplicate_name",
|
||||
"已存在同名定时任务: name=" + dto.getName() + ", agentId=" + dto.getAgentId());
|
||||
}
|
||||
|
||||
// 加锁保证 cancel + register 的原子性(ReentrantLock 支持同线程重入)
|
||||
schedulerLock.lock();
|
||||
|
||||
@ -555,14 +555,13 @@ public class SkillPackageResolver {
|
||||
if (kbId == null) {
|
||||
kbId = wikiWrapperFactory.resolveKbId(manifest.getKnowledge().getBindKb());
|
||||
if (kbId == null) {
|
||||
String slug = manifest.getKnowledge().getBindKb();
|
||||
log.warn("Skill '{}' has type=knowledge but bind_kb '{}' did not resolve to a KB",
|
||||
resolved.getName(), manifest.getKnowledge().getBindKb());
|
||||
resolved.getName(), slug);
|
||||
deregisterSkillWrappers(resolved.getId());
|
||||
// Still surface the resolution failure as a missing
|
||||
// requirement so the UI shows the skill as
|
||||
// SETUP_NEEDED rather than READY-but-broken.
|
||||
resolved.setMissingDependencies(java.util.List.of(
|
||||
"kb:" + manifest.getKnowledge().getBindKb()));
|
||||
markBindingFailure(resolved,
|
||||
"kb:" + slug,
|
||||
"Knowledge skill bind_kb '" + slug + "' did not resolve to any Wiki KB");
|
||||
return;
|
||||
}
|
||||
manifest.getKnowledge().setBoundKbId(kbId);
|
||||
@ -604,7 +603,48 @@ public class SkillPackageResolver {
|
||||
manifest.setAllowedTools(mergedAllowed);
|
||||
}
|
||||
|
||||
private void deregisterSkillWrappers(Long skillId) {
|
||||
/**
|
||||
* RFC-090 review #2 — when a knowledge / acp skill's external
|
||||
* binding (bind_kb / endpoint) cannot be resolved, downgrade the
|
||||
* resolved view so {@code passesActiveGate} fails. Without this,
|
||||
* the synthesized default feature still evaluates READY (because
|
||||
* the unresolved binding isn't expressed as a manifest requirement
|
||||
* the dependency checker can fail), the skill enters the active
|
||||
* set, and the LLM advertises tools it can never actually use.
|
||||
*
|
||||
* <p>Triple-belt-and-braces:
|
||||
* <ol>
|
||||
* <li>{@code missingDependencies} populated for the UI.</li>
|
||||
* <li>{@code dependencyReady=false} so legacy gate path also fails.</li>
|
||||
* <li>{@code runtimeAvailable=false} so the manifest-aware path
|
||||
* in {@code passesActiveGate} fails too — even though the
|
||||
* feature would otherwise be READY.</li>
|
||||
* </ol>
|
||||
* The later {@code resolveRuntimeAvailability} step won't undo
|
||||
* these because it only flips {@code runtimeAvailable=false}, it
|
||||
* never flips it back to true.
|
||||
*/
|
||||
private void markBindingFailure(ResolvedSkill resolved, String missingKey, String summary) {
|
||||
resolved.setMissingDependencies(java.util.List.of(missingKey));
|
||||
resolved.setDependencyReady(false);
|
||||
resolved.setDependencySummary(summary);
|
||||
resolved.setRuntimeAvailable(false);
|
||||
if (resolved.getResolutionError() == null) {
|
||||
resolved.setResolutionError(summary);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-090 review #3 — explicit deregistration entry point. Called
|
||||
* from {@code SkillService.toggleSkill (disable)} / uninstall /
|
||||
* hardDelete so wrapper tools don't outlive the skill's lifecycle.
|
||||
*
|
||||
* <p>Without this, the {@code availabilityCheck} supplier closes
|
||||
* over the {@link ResolvedSkill} captured at registration time
|
||||
* and keeps returning {@code enabled=true} forever. Stale wrapper
|
||||
* advertisements survive a disable/uninstall.
|
||||
*/
|
||||
public void deregisterSkillWrappers(Long skillId) {
|
||||
if (skillId == null) return;
|
||||
java.util.Set<String> previous = registeredWrappers.remove(skillId);
|
||||
if (previous == null || previous.isEmpty()) return;
|
||||
@ -615,6 +655,7 @@ public class SkillPackageResolver {
|
||||
log.debug("unregister wrapper {} failed: {}", name, e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("Deregistered {} wrapper tool(s) for skill id={}", previous.size(), skillId);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -648,11 +689,13 @@ public class SkillPackageResolver {
|
||||
if (endpointId == null) {
|
||||
endpointId = acpWrapperFactory.resolveEndpointId(manifest.getAcp().getEndpoint());
|
||||
if (endpointId == null) {
|
||||
String slug = manifest.getAcp().getEndpoint();
|
||||
log.warn("Skill '{}' type=acp but endpoint '{}' did not resolve",
|
||||
resolved.getName(), manifest.getAcp().getEndpoint());
|
||||
resolved.getName(), slug);
|
||||
deregisterSkillWrappers(resolved.getId());
|
||||
resolved.setMissingDependencies(java.util.List.of(
|
||||
"acp:" + manifest.getAcp().getEndpoint()));
|
||||
markBindingFailure(resolved,
|
||||
"acp:" + slug,
|
||||
"ACP skill endpoint '" + slug + "' did not resolve to any registered ACP endpoint");
|
||||
return;
|
||||
}
|
||||
manifest.getAcp().setResolvedEndpointId(endpointId);
|
||||
|
||||
@ -187,6 +187,19 @@ public class SkillRuntimeService {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-090 review #3 — explicit lifecycle hook so SkillService
|
||||
* can deregister wrapper tools without poking at the resolver
|
||||
* directly. Safe to call for skill ids that never had wrappers.
|
||||
*/
|
||||
public void deregisterSkillWrappers(Long skillId) {
|
||||
try {
|
||||
packageResolver.deregisterSkillWrappers(skillId);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to deregister wrappers for skill {}: {}", skillId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据名称查找 active skill
|
||||
*/
|
||||
|
||||
@ -303,7 +303,11 @@ public class SkillService {
|
||||
if ("archive".equals(workspaceProperties.getDeletePolicy())) {
|
||||
workspaceManager.archiveWorkspace(skill.getName());
|
||||
}
|
||||
// RFC-090 review #3 — refresh won't deregister wrappers for a
|
||||
// soft-deleted row (it only resolves rows still in
|
||||
// listEnabledSkills), so do it explicitly here.
|
||||
if (runtimeService != null) {
|
||||
runtimeService.deregisterSkillWrappers(id);
|
||||
runtimeService.refreshActiveSkills();
|
||||
}
|
||||
}
|
||||
@ -328,7 +332,9 @@ public class SkillService {
|
||||
|
||||
workspaceManager.purgeWorkspace(skill.getName());
|
||||
|
||||
// RFC-090 review #3 — same explicit deregister as uninstall.
|
||||
if (runtimeService != null) {
|
||||
runtimeService.deregisterSkillWrappers(id);
|
||||
runtimeService.refreshActiveSkills();
|
||||
}
|
||||
}
|
||||
@ -357,6 +363,14 @@ public class SkillService {
|
||||
skillMapper.updateById(skill);
|
||||
log.info("Skill {} {}", skill.getName(), enabled ? "enabled" : "disabled");
|
||||
|
||||
// RFC-090 review #3 — when disabling, explicitly tear down any
|
||||
// registered wrapper tools (knowledge / acp). Without this the
|
||||
// wrappers stay advertised because the availability supplier
|
||||
// closes over the snapshot ResolvedSkill captured at registration.
|
||||
if (!enabled && runtimeService != null) {
|
||||
runtimeService.deregisterSkillWrappers(id);
|
||||
}
|
||||
|
||||
// 刷新 runtime cache
|
||||
if (runtimeService != null) {
|
||||
runtimeService.refreshActiveSkills();
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
-- Issue #50 follow-up: V69 dedup grouped MIN(id) without filtering deleted=0.
|
||||
-- If a (workspace_id, agent_id, name) tuple had [deleted=1 row id=100,
|
||||
-- deleted=0 row id=200], V69 kept id=100 and physically removed id=200,
|
||||
-- making the active job invisible to the runtime (which queries deleted=0).
|
||||
-- CronJobEntity.deleted is declared but never set by current code, so
|
||||
-- defensively treat any deleted=1 row as stale and remove it physically.
|
||||
|
||||
-- Step 1: physically purge any deleted=1 rows. The cron service treats
|
||||
-- the entity as hard-delete-only (no @TableLogic, no global logic-delete
|
||||
-- config), so any deleted=1 rows are legacy artifacts and unsafe to keep.
|
||||
DELETE FROM mate_cron_job WHERE deleted = 1;
|
||||
|
||||
-- Step 2: idempotent re-dedup against active rows only. If V69 already
|
||||
-- left the table clean, this is a no-op. If V69 picked a deleted=1 row
|
||||
-- as the survivor and Step 1 just removed it, an active duplicate may
|
||||
-- still need re-converging.
|
||||
DELETE FROM mate_cron_job
|
||||
WHERE deleted = 0
|
||||
AND id NOT IN (
|
||||
SELECT keep_id FROM (
|
||||
SELECT MIN(id) AS keep_id
|
||||
FROM mate_cron_job
|
||||
WHERE deleted = 0
|
||||
GROUP BY workspace_id, agent_id, name
|
||||
)
|
||||
);
|
||||
@ -0,0 +1,16 @@
|
||||
-- Issue #50 follow-up: see h2/V70 for rationale. MySQL doesn't allow
|
||||
-- DELETE with a subquery scanning the same table directly, so use
|
||||
-- the LEFT JOIN + IS NULL pattern.
|
||||
|
||||
-- Step 1: physically purge any deleted=1 rows.
|
||||
DELETE FROM mate_cron_job WHERE deleted = 1;
|
||||
|
||||
-- Step 2: idempotent re-dedup against active rows only.
|
||||
DELETE t FROM mate_cron_job t
|
||||
LEFT JOIN (
|
||||
SELECT MIN(id) AS keep_id
|
||||
FROM mate_cron_job
|
||||
WHERE deleted = 0
|
||||
GROUP BY workspace_id, agent_id, name
|
||||
) k ON t.id = k.keep_id
|
||||
WHERE t.deleted = 0 AND k.keep_id IS NULL;
|
||||
Loading…
Reference in New Issue
Block a user