@zq-silk/yui 0.15.11 → 0.15.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/ARCHITECTURE.md +8 -4
  2. package/ARCHITECTURE.zh-CN.md +5 -2
  3. package/README.md +13 -5
  4. package/dist/agentRun/agentRun.js +3 -0
  5. package/dist/cli/commandCatalog.js +38 -10
  6. package/dist/cli/interactionPolicy.js +4 -0
  7. package/dist/cli/managedDiagnostics.js +1 -1
  8. package/dist/cli.js +46 -15
  9. package/dist/commands/executionAuditCommands.js +10 -0
  10. package/dist/commands/globalRoleCommands.js +27 -2
  11. package/dist/commands/projectCommands.js +44 -15
  12. package/dist/commands/taskCommands.js +95 -33
  13. package/dist/commands/taskIntegrationCommands.js +3 -1
  14. package/dist/commands/taskOverviewCommand.js +4 -3
  15. package/dist/commands/taskPublicationAdoptCommand.js +127 -0
  16. package/dist/commands/taskPublicationCommands.js +11 -2
  17. package/dist/commands/taskPublicationVerifyCommand.js +23 -39
  18. package/dist/commands/taskRemoteDeliveryCommand.js +18 -7
  19. package/dist/context/runContextPack.js +3 -0
  20. package/dist/context/taskCatalog.js +187 -0
  21. package/dist/context/taskContext.js +17 -4
  22. package/dist/controller/controller.js +11 -2
  23. package/dist/controller/fileSchedulerStoreAdapter.js +80 -19
  24. package/dist/controller/globalInputDelivery.js +13 -0
  25. package/dist/controller/providerRetryAdmission.js +100 -0
  26. package/dist/controller/providerRetryDelivery.js +218 -0
  27. package/dist/controller/runtime.js +36 -1
  28. package/dist/integration/gitIntegrationService.js +19 -6
  29. package/dist/lifecycle/exactRunTerminalization.js +4 -1
  30. package/dist/message/globalProviderRetry.js +15 -0
  31. package/dist/observability/executionAudit.js +19 -0
  32. package/dist/repository/gitWorkspace.js +371 -105
  33. package/dist/repository/projectMaintenanceLock.js +75 -18
  34. package/dist/repository/taskWorkspaceCoordinator.js +71 -124
  35. package/dist/repository/taskWorkspacePreparer.js +86 -24
  36. package/dist/repository/workspaceCleanupInspection.js +187 -0
  37. package/dist/runtime/agentError.js +5 -3
  38. package/dist/runtime/agentHost.js +28 -11
  39. package/dist/runtime/builtinAgentErrorMappers.js +91 -0
  40. package/dist/runtime/codexAppServerRuntime.js +34 -3
  41. package/dist/runtime/providerControl.js +5 -1
  42. package/dist/runtime/providerRetry.js +198 -0
  43. package/dist/runtime/providerRuntimeIdentity.js +28 -2
  44. package/dist/runtime/sessionTokenMetrics.js +15 -5
  45. package/dist/runtime/structuredProviderHost.js +6 -2
  46. package/dist/runtime/taskUsageMetrics.js +275 -0
  47. package/dist/scheduler/activeRoleRunDelivery.js +12 -0
  48. package/dist/scheduler/leaderWakeupProcessor.js +5 -0
  49. package/dist/scheduler/taskExecutionProjection.js +26 -5
  50. package/dist/scheduler/taskObservabilityProjection.js +6 -44
  51. package/dist/storage/sqliteSchema.js +31 -0
  52. package/dist/storage/sqliteStore.js +17 -0
  53. package/dist/storage/storageVersions.js +1 -1
  54. package/dist/storage/storeRpc.js +1 -0
  55. package/dist/storage/taskCatalog.js +123 -0
  56. package/dist/storage/taskStore.js +2 -0
  57. package/dist/task/archiveDiagnostics.js +1 -0
  58. package/dist/task/archivePreflight.js +124 -0
  59. package/dist/task/publicationAdoption.js +56 -0
  60. package/dist/task/publicationReference.js +10 -0
  61. package/dist/task/remoteDelivery.js +31 -16
  62. package/dist/web/assets/client/app.js +92 -17
  63. package/dist/web/assets/client/components.js +55 -13
  64. package/dist/web/assets/client/i18n.js +72 -4
  65. package/dist/web/assets/client/taskSurface.js +2 -1
  66. package/dist/web/assets/client/view.js +35 -7
  67. package/dist/web/assets/shell.js +6 -0
  68. package/dist/web/assets/styles/layout.js +7 -0
  69. package/dist/web/webServer.js +14 -3
  70. package/dist/web/webSnapshot.js +12 -3
  71. package/dist/workspace/cleanupInspection.js +63 -0
  72. package/dist/workspace/workItemChangeSetManager.js +110 -50
  73. package/docs/agent-result-consumption.md +4 -0
  74. package/docs/agent-result-consumption.zh-CN.md +3 -0
  75. package/docs/agent-runtime-drivers.md +7 -0
  76. package/docs/agent-runtime-drivers.zh-CN.md +5 -0
  77. package/docs/architecture/README.md +2 -0
  78. package/docs/architecture/README.zh-CN.md +3 -1
  79. package/docs/architecture/capabilities-and-resources.md +30 -5
  80. package/docs/architecture/capabilities-and-resources.zh-CN.md +23 -3
  81. package/docs/managed-turn-and-session-runtime.md +47 -0
  82. package/docs/managed-turn-and-session-runtime.zh-CN.md +40 -0
  83. package/docs/observability/README.md +62 -0
  84. package/docs/observability/README.zh-CN.md +47 -0
  85. package/docs/project-refresh.md +77 -0
  86. package/docs/project-refresh.zh-CN.md +59 -0
  87. package/docs/provider-retry.md +70 -0
  88. package/docs/task-delivery.md +133 -13
  89. package/docs/task-delivery.zh-CN.md +99 -10
  90. package/docs/task-discovery.md +102 -0
  91. package/docs/task-discovery.zh-CN.md +86 -0
  92. package/docs/testing/verification-levels.md +16 -0
  93. package/docs/testing/verification-levels.zh-CN.md +13 -1
  94. package/i18n/README.zh-CN.md +13 -7
  95. package/package.json +1 -1
  96. package/skills/yui-leader/references/execution.md +3 -2
  97. package/skills/yui-operator/SKILL.md +13 -2
  98. package/skills/yui-reviewer/SKILL.md +4 -0
  99. package/skills/yui-runtime/SKILL.md +17 -2
  100. package/skills/yui-runtime/references/publication.md +26 -4
  101. package/skills/yui-runtime/references/recovery.md +24 -0
@@ -0,0 +1,123 @@
1
+ /** Current facts, not another persisted projection or execution status. */
2
+ export const CATALOG_ATTENTION = [
3
+ "openInputs", "pendingOperations", "unknownOperations", "executionSignals"
4
+ ];
5
+ /**
6
+ * All filtering/counting stays in SQLite. Only page-sized rows and fixed
7
+ * attention samples cross into JS; no Task/Run/Message/Event body is decoded.
8
+ * The existing catalog and record tables remain the only stored authorities.
9
+ */
10
+ export function queryTaskCatalog(db, query) {
11
+ const scope = ["1=1"];
12
+ const params = { limit: query.limit };
13
+ if (query.taskId !== undefined) {
14
+ scope.push("c.task_id = @taskId");
15
+ params.taskId = query.taskId;
16
+ }
17
+ if (!query.all)
18
+ scope.push("c.status <> 'archived'");
19
+ const match = ["1=1"];
20
+ if (query.status !== undefined) {
21
+ match.push("status = @status");
22
+ params.status = query.status;
23
+ }
24
+ if (query.project !== undefined) {
25
+ match.push(`EXISTS (SELECT 1 FROM task_records r, json_each(r.payload, '$.projectBindings') p
26
+ WHERE r.task_id = facts.id AND json_extract(p.value, '$.projectId') = @project)`);
27
+ params.project = query.project;
28
+ }
29
+ if (query.search) {
30
+ match.push(`EXISTS (SELECT 1 FROM task_records r WHERE r.task_id = facts.id AND
31
+ (instr(lower(json_extract(r.payload, '$.title')), lower(@search)) > 0
32
+ OR instr(lower(r.task_id), lower(@search)) > 0
33
+ OR EXISTS (SELECT 1 FROM json_each(r.payload, '$.tags') tag
34
+ WHERE instr(lower(tag.value), lower(@search)) > 0)
35
+ OR EXISTS (SELECT 1 FROM json_each(r.payload, '$.projectBindings') b
36
+ JOIN projects p ON p.id = json_extract(b.value, '$.projectId')
37
+ WHERE instr(lower(json_extract(p.payload, '$.name')), lower(@search)) > 0)))`);
38
+ params.search = query.search;
39
+ }
40
+ if (query.attention !== undefined)
41
+ match.push(`${query.attention} > 0`);
42
+ const position = (name, op) => {
43
+ const value = query[name];
44
+ if (value === undefined)
45
+ return;
46
+ params[`${name}Date`] = value.createdAt;
47
+ params[`${name}Id`] = value.id;
48
+ match.push(`(createdAt, id) ${op} (@${name}Date, @${name}Id)`);
49
+ };
50
+ position("through", "<=");
51
+ // A fixed upper key excludes newly-created Tasks on later pages. Updates
52
+ // to mutable filters are current reads, not a snapshot/session protocol.
53
+ const after = [];
54
+ if (query.after !== undefined) {
55
+ params.afterDate = query.after.createdAt;
56
+ params.afterId = query.after.id;
57
+ after.push("(createdAt, id) > (@afterDate, @afterId)");
58
+ }
59
+ const count = (table, condition) => `(SELECT count(*) FROM ${table} x WHERE x.task_id = c.task_id AND (${condition}))`;
60
+ // Execution signals deliberately preserve raw inspectable conditions:
61
+ // live runs (including unknown admission/identity/stall), unresolved work,
62
+ // integrations, and pending Leader recovery. They are NOT a second
63
+ // classifier for working/blocked/success; precise execution stays in detail.
64
+ // Including all live runs is conservative: an off-page runtime attention
65
+ // cannot disappear just because a lightweight read did not fold its history.
66
+ const cte = `WITH facts AS MATERIALIZED (
67
+ SELECT c.task_id AS id, c.created_at AS createdAt, c.updated_at AS updatedAt, c.status,
68
+ ${count("work_items", "1=1")} AS workItems,
69
+ ${count("turns", "status = 'active'")} AS activeRuns,
70
+ ${count("input_requests", "status = 'open'")} AS openInputs,
71
+ ${count("durable_jobs", "status IN ('queued','running')")} AS pendingOperations,
72
+ ${count("durable_jobs", "status = 'unknown-needs-attention'")} AS unknownOperations,
73
+ CASE WHEN c.status IN ('active','draft') THEN
74
+ ${count("turns", "status = 'active'")} +
75
+ ${count("work_items", "status = 'open' OR (status = 'accepted' AND json_extract(payload, '$.currentExecutionGroupId') IS NOT NULL)")} +
76
+ ${count("integration_attempts", "status IN ('running','validating','blocked','conflicted','failed')")} +
77
+ ${count("review_rounds", "status IN ('pending','running','failed')")} +
78
+ ${count("task_projections", "kind = 'leader-failure' AND payload IS NOT NULL AND payload <> 'null'")} +
79
+ ${count("mailboxes", "role_name = 'leader' AND (json_type(pending) = 'object' OR json_type(processing) = 'object')")}
80
+ ELSE 0 END AS executionSignals
81
+ FROM tasks_catalog c WHERE ${scope.join(" AND ")}
82
+ ), matching AS MATERIALIZED (SELECT * FROM facts WHERE ${match.join(" AND ")})
83
+ SELECT
84
+ (SELECT json_group_array(json_object('status',status,'count',n))
85
+ FROM (SELECT status,count(*) n FROM facts GROUP BY status)) AS counts,
86
+ (SELECT count(*) FROM matching) AS total,
87
+ (SELECT json_object('id',id,'createdAt',createdAt) FROM matching
88
+ ORDER BY createdAt DESC,id DESC LIMIT 1) AS upper,
89
+ ${CATALOG_ATTENTION.map(kind => `
90
+ (SELECT sum(${kind}) FROM facts) AS ${kind}Count,
91
+ (SELECT count(*) FROM facts WHERE ${kind} > 0) AS ${kind}Tasks,
92
+ (SELECT json_group_array(id) FROM (SELECT id FROM facts WHERE ${kind} > 0
93
+ ORDER BY createdAt,id LIMIT 4)) AS ${kind}Ids`).join(",")},
94
+ (SELECT json_group_array(json_object(
95
+ 'id',page.id,'createdAt',page.createdAt,'updatedAt',page.updatedAt,'status',page.status,
96
+ 'title',substr(json_extract(r.payload,'$.title'),1,256),
97
+ 'summary',substr(json_extract(r.brief,'$.leaderSummary'),1,512),
98
+ 'summaryPresent',json(CASE WHEN r.brief IS NULL THEN 'false' ELSE 'true' END),
99
+ 'workItems',page.workItems,'activeRuns',page.activeRuns,
100
+ 'openInputs',page.openInputs,'pendingOperations',page.pendingOperations,
101
+ 'unknownOperations',page.unknownOperations,'executionSignals',page.executionSignals)
102
+ ORDER BY page.createdAt,page.id)
103
+ FROM (SELECT * FROM matching ${after.length ? `WHERE ${after.join(" AND ")}` : ""}
104
+ ORDER BY createdAt,id LIMIT @limit) page
105
+ JOIN task_records r ON r.task_id = page.id) AS rows`;
106
+ const result = db.prepare(cte).get(params);
107
+ const counts = { draft: 0, active: 0, completed: 0, cancelled: 0, archived: 0, total: 0 };
108
+ for (const entry of JSON.parse(result.counts)) {
109
+ counts[entry.status] = entry.count;
110
+ counts.total += entry.count;
111
+ }
112
+ return {
113
+ rows: JSON.parse(result.rows),
114
+ total: result.total,
115
+ through: result.upper === null ? null : JSON.parse(result.upper),
116
+ counts,
117
+ attention: Object.fromEntries(CATALOG_ATTENTION.map(kind => [kind, {
118
+ count: Number(result[`${kind}Count`] ?? 0),
119
+ taskCount: Number(result[`${kind}Tasks`] ?? 0),
120
+ taskIds: JSON.parse(result[`${kind}Ids`])
121
+ }]))
122
+ };
123
+ }
@@ -448,6 +448,8 @@ export function storedPublicationReference(value) {
448
448
  fields.push("targetBranch");
449
449
  if (reference.localCommit !== undefined)
450
450
  fields.push("localCommit");
451
+ if (reference.headCommit !== undefined)
452
+ fields.push("headCommit");
451
453
  if (reference.remoteCommit !== undefined)
452
454
  fields.push("remoteCommit");
453
455
  if (reference.evidence !== undefined)
@@ -122,6 +122,7 @@ export function taskArchiveDiagnostics(store, task) {
122
122
  export function renderArchiveDiagnostics(data) {
123
123
  return [
124
124
  `Archived: ${data.archived}; forced: ${data.forced}; cleanup finished: ${data.cleanupFinished}`,
125
+ `Retained references: ${data.retainedResources.length}; a finished cleanup attempt is not physical resource release evidence.`,
125
126
  ...data.warnings.map(w => `Warning [${w.resource}]: ${w.detail}`),
126
127
  ...data.retainedResources.map(r => `Retained [${r.resource}]: ${r.detail}${r.paths ? ` (${r.paths.join(", ")})` : ""}`)
127
128
  ].join("\n") + "\n";
@@ -0,0 +1,124 @@
1
+ import { readArchiveDelivery } from "../repository/workspaceCleanupInspection.js";
2
+ import { WorkspaceCleanupBlockedError } from "../repository/taskWorkspacePreparer.js";
3
+ import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
4
+ import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
5
+ import { cleanupCheckFromError, renderCleanupCheck } from "../workspace/cleanupInspection.js";
6
+ /** Semantic admission facts. Cleanup still has its own resource boundary. */
7
+ export function archiveSettlementChecks(store, task) {
8
+ const checks = [];
9
+ const add = (resource, reason, detail, expected, observed, action) => checks.push({ resource, reason, status: "blocked", detail, expected, observed, sources: [resource], actions: [action] });
10
+ for (const input of store.listInputRequests(task.id).filter(i => i.status === "open")) {
11
+ add(`input:${task.id}/${input.id}`, "unresolved-input", "Open user input is not answered by archive.", "resolved input", input.status, `yui task input show ${task.id}/${input.id}`);
12
+ }
13
+ for (const item of store.listWorkItems(task.id).filter(i => i.status === "open")) {
14
+ add(`work-item:${task.id}/${item.id}`, "owner-unsettled", "WorkItem must be accepted or explicitly retired before archive.", ["accepted", "retired"], item.status, `yui task work show ${task.id}/${item.id}`);
15
+ }
16
+ for (const attempt of store.listIntegrationAttempts(task.id).filter(a => ["running", "blocked", "conflicted", "validating"].includes(a.status))) {
17
+ add(`integration-attempt:${task.id}/${attempt.id}`, "unresolved-integration", "Task has an unresolved Integration Attempt.", "terminal attempt", attempt.status, `yui task integration show ${task.id}/${attempt.id}`);
18
+ }
19
+ for (const job of store.listDurableJobs(task.id).filter(j => ["queued", "running"].includes(j.status) || (j.status === "unknown-needs-attention" && j.acknowledgedAt === undefined))) {
20
+ add(`job:${task.id}/${job.id}`, "active-durable-job", "Task has an active or unacknowledged DurableJob.", "settled job", job.status, `yui job get --task ${task.id} --job ${job.id}`);
21
+ }
22
+ return checks;
23
+ }
24
+ /** Unknown execution is never permission to remove workspaces, even after
25
+ * force admission. These facts are reloaded in the actual force cleanup path.
26
+ */
27
+ export function archiveExecutionChecks(store, taskId) {
28
+ const checks = [];
29
+ const add = (resource, reason, detail, observed, action) => checks.push({ resource, reason, status: "unknown", detail, expected: "settled execution",
30
+ observed, sources: [resource], actions: [action] });
31
+ for (const run of store.listRuns(taskId).filter(r => r.status === "active")) {
32
+ add(`run:${taskId}/${run.id}`, "active-turn", "AgentRun remains active; archive is not stop evidence.", run.status, `yui task run show ${taskId}/${run.id}`);
33
+ }
34
+ for (const job of store.listDurableJobs(taskId).filter(j => ["queued", "running", "unknown-needs-attention"].includes(j.status))) {
35
+ add(`job:${taskId}/${job.id}`, "unresolved-execution", "DurableJob may still hold resources; acknowledgement is not physical exit evidence.", job.status, `yui job get --task ${taskId} --job ${job.id}`);
36
+ }
37
+ for (const role of store.listRoles(taskId)) {
38
+ const resource = `role:${taskId}/${role.name}`;
39
+ const action = `yui task role session inspect ${taskId} ${role.name}`;
40
+ const provider = store.getTaskRoleSessionSet(taskId, role.name)?.providerBinding;
41
+ const mailbox = store.getWorkMailbox({ kind: "role", taskId, roleName: role.name });
42
+ if (provider?.run != null && !["completed", "failed", "cancelled"].includes(provider.run.status)) {
43
+ add(resource, "unresolved-provider-input", "Original provider input outcome is unresolved; do not replay it.", { attemptId: provider.run.attemptId, status: provider.run.status }, action);
44
+ }
45
+ if (mailbox?.processing != null && (provider?.run == null
46
+ || !["completed", "failed", "cancelled"].includes(provider.run.status))) {
47
+ add(resource, "unresolved-mailbox", "Original mailbox claim has no proven terminal; archive does not acknowledge it.", { batchId: mailbox.processing.batchId }, action);
48
+ }
49
+ if (hasRuntimeLifecycleWork(store.getWorkMailbox(runtimeLifecycleTarget({ scope: "task", taskId, roleName: role.name })))) {
50
+ add(resource, "unresolved-runtime-lifecycle", "Role has unsettled runtime lifecycle work.", "pending", action);
51
+ }
52
+ }
53
+ return checks;
54
+ }
55
+ export async function inspectTaskArchive(coordinator, request) {
56
+ const { store, preparer, runtime } = coordinator;
57
+ const task = store.getTask(request.taskId);
58
+ if (task === null)
59
+ throw new Error(`Task not found: ${request.taskId}.`);
60
+ const settlement = archiveSettlementChecks(store, task);
61
+ const delivery = await readArchiveDelivery(store, preparer.git, task, request.force);
62
+ const deliveryChecks = delivery.projects.filter(p => p.codeDelivery !== "none" && (!p.merged || !p.verified))
63
+ .map(p => ({ resource: `delivery:${task.id}/${p.projectId}`, reason: "delivery-coverage", status: "blocked",
64
+ detail: p.reason, expected: { merged: true, verified: true, localCommit: p.expectedLocalCommit },
65
+ observed: { coverage: p.coverage, merged: p.merged, verified: p.verified, localCommit: p.publication?.localCommit ?? null },
66
+ sources: [`task:${task.id}`, ...(p.publication === null ? [] : [`publication:${task.id}/${p.publication.id}`])],
67
+ actions: [`yui task remote-delivery ${task.id}`] }));
68
+ const execution = archiveExecutionChecks(store, task.id);
69
+ const runtimeResource = `runtime:${task.id}`;
70
+ if (runtime.assertTaskPhysicalResourcesReleased === undefined) {
71
+ execution.push(...cleanupCheckFromError(null, runtimeResource, [`task:${task.id}`], [`yui task role status ${task.id}`]));
72
+ }
73
+ else {
74
+ try {
75
+ await runtime.assertTaskPhysicalResourcesReleased(task.id);
76
+ }
77
+ catch (error) {
78
+ execution.push({ resource: runtimeResource, reason: "physical-resources-unreleased", status: "unknown",
79
+ detail: error instanceof WorkspaceCleanupBlockedError ? error.message
80
+ : "Exact physical resource release is not established by the runtime inspection.",
81
+ expected: "proven physical absence",
82
+ observed: error instanceof WorkspaceCleanupBlockedError ? { reason: error.reason, resource: error.resource } : "unavailable",
83
+ sources: [`task:${task.id}`],
84
+ actions: [`yui task role status ${task.id}`, `yui session reconcile --report`] });
85
+ }
86
+ }
87
+ const resources = [];
88
+ for (const workspace of store.listManagedWorkspaces(task.id)) {
89
+ const disposition = workspace.owner.type === "work-item"
90
+ && store.getWorkItem(task.id, workspace.owner.workItemId)?.status === "retired" ? "abandoned" : request.disposition;
91
+ let checks;
92
+ try {
93
+ checks = await preparer.inspectWorkspaceCleanup(workspace, disposition, request.force);
94
+ }
95
+ catch (error) {
96
+ checks = cleanupCheckFromError(error, managedWorkspaceKey(workspace.owner), [managedWorkspaceKey(workspace.owner)], [`yui task show ${task.id}`]);
97
+ }
98
+ resources.push({ resource: managedWorkspaceKey(workspace.owner), owner: workspace.owner,
99
+ status: checks.some(c => c.status === "unknown") ? "unknown" : checks.length > 0 ? "blocked" : "checked",
100
+ checks });
101
+ }
102
+ const eligible = ["completed", "cancelled", "archived"].includes(task.status);
103
+ return { taskId: task.id, observedAt: new Date().toISOString(), disposition: request.disposition, force: request.force,
104
+ readOnly: true, authorizesCleanup: false,
105
+ archive: { status: task.status, eligible, alreadyArchived: task.status === "archived",
106
+ settlement, delivery: deliveryChecks, forceBypassesSettlement: request.force,
107
+ requiresIndependentAuthorization: true },
108
+ cleanup: { execution, resources },
109
+ note: "Current observations only. Execution reloads the checks. Force commits archive first and retains unsafe resources; a finished attempt does not prove resource release." };
110
+ }
111
+ export function renderTaskArchivePreflight(data) {
112
+ return [
113
+ `Archive preflight: ${data.taskId} (${data.disposition}${data.force ? ", force" : ""}); read-only, not authorization`,
114
+ `Task: ${data.archive.status}; terminal eligibility: ${data.archive.eligible}`,
115
+ ...data.archive.settlement.map(c => `Admission${data.force ? " (force preserves)" : ""}: ${renderCleanupCheck(c)}`),
116
+ ...data.archive.delivery.map(c => `Delivery${data.force || data.disposition === "abandoned" ? " (advisory for admission)" : ""}: ${renderCleanupCheck(c)}`),
117
+ ...data.cleanup.execution.map(c => `Cleanup: ${renderCleanupCheck(c)}`),
118
+ ...data.cleanup.resources.flatMap(r => [
119
+ `Workspace [${r.resource}]: ${r.status}`,
120
+ ...r.checks.map(c => ` ${renderCleanupCheck(c)}`)
121
+ ]),
122
+ data.note
123
+ ].join("\n") + "\n";
124
+ }
@@ -0,0 +1,56 @@
1
+ import { contextContentDigest } from "../context/contextSnapshot.js";
2
+ import { publicationExternalKey } from "./publicationReference.js";
3
+ export const PUBLICATION_ADOPTED_EVENT = "publication.candidate-adopted";
4
+ /**
5
+ * An explicit semantic decision over fixed Git endpoints, not an ancestry
6
+ * heuristic or a replacement completion head. Publication metadata/verification
7
+ * successors may retain it only while every intervening local candidate agrees.
8
+ */
9
+ export function publicationAdoption(publication, completion, acceptedCommit, events, publications, integrations) {
10
+ const unavailable = { event: null, reason: null };
11
+ if (completion === undefined || acceptedCommit === null)
12
+ return unavailable;
13
+ const lineage = new Set();
14
+ let current = publication;
15
+ while (current !== undefined && !lineage.has(current.id)
16
+ && current.taskId === publication.taskId
17
+ && current.projectId === publication.projectId
18
+ && current.externalKind === publication.externalKind
19
+ && publicationExternalKey(current) === publicationExternalKey(publication)
20
+ && current.localCommit === publication.localCommit) {
21
+ lineage.add(current.id);
22
+ current = publications.find(p => p.id === current.supersedes);
23
+ }
24
+ const event = events.slice().reverse().find(event => {
25
+ const p = event.payload;
26
+ return event.type === PUBLICATION_ADOPTED_EVENT
27
+ && event.taskId === publication.taskId
28
+ && p.completionEventId === completion.id
29
+ && p.acceptedCommit === acceptedCommit
30
+ && p.projectId === publication.projectId
31
+ && p.localCommit === publication.localCommit
32
+ && lineage.has(p.publicationId ?? "");
33
+ });
34
+ if (event === undefined)
35
+ return unavailable;
36
+ const p = event.payload;
37
+ if (!p.acceptance?.trim()
38
+ || !["user", "operator", "leader"].includes(p.by ?? "")
39
+ || !/^[0-9a-f]{64}$/u.test(p.diffDigest ?? "")
40
+ || !/^[0-9a-f]{40}$/u.test(p.acceptedTree ?? "")
41
+ || !/^[0-9a-f]{40}$/u.test(p.candidateTree ?? "")) {
42
+ return { event: null, reason: `Adoption ${event.id} has incomplete fixed evidence.` };
43
+ }
44
+ if (p.integrationId === undefined)
45
+ return { event, reason: null };
46
+ const integration = integrations.find(i => i.id === p.integrationId);
47
+ const valid = integration !== undefined && integration.status === "committed"
48
+ && integration.taskId === publication.taskId
49
+ && integration.projectId === publication.projectId
50
+ && integration.afterCommit === publication.localCommit
51
+ && contextContentDigest(integration) === p.integrationDigest;
52
+ return valid ? { event, reason: null } : {
53
+ event: null,
54
+ reason: `Adoption ${event.id} references Integration ${p.integrationId}, whose committed candidate evidence is missing or changed (${integration?.status ?? "missing"}).`
55
+ };
56
+ }
@@ -35,6 +35,9 @@ export function createPublicationReference(id, taskId, input, now) {
35
35
  ...(input.localCommit === undefined ? {} : {
36
36
  localCommit: requireCommit(input.localCommit, "Publication local commit")
37
37
  }),
38
+ ...(input.headCommit === undefined ? {} : {
39
+ headCommit: requireCommit(input.headCommit, "Publication remote head")
40
+ }),
38
41
  ...(input.remoteCommit === undefined ? {} : {
39
42
  remoteCommit: requireCommit(input.remoteCommit, "Publication remote commit")
40
43
  }),
@@ -82,6 +85,13 @@ export function validatePublicationReference(reference) {
82
85
  if (reference.localCommit !== undefined) {
83
86
  requireCommit(reference.localCommit, "Publication local commit");
84
87
  }
88
+ if (reference.headCommit !== undefined) {
89
+ requireCommit(reference.headCommit, "Publication remote head");
90
+ if (reference.verification === "verified"
91
+ && reference.headCommit !== reference.localCommit) {
92
+ throw new Error("Verified publication remote head must match its local commit.");
93
+ }
94
+ }
85
95
  if (reference.remoteCommit !== undefined) {
86
96
  requireCommit(reference.remoteCommit, "Publication remote commit");
87
97
  }
@@ -1,3 +1,4 @@
1
+ import { publicationAdoption } from "./publicationAdoption.js";
1
2
  /**
2
3
  * One read-only delivery projection over existing Task, workspace, completion,
3
4
  * and Publication facts. It never writes a merged flag or queries a provider.
@@ -19,13 +20,19 @@ export function projectTaskRemoteDelivery(facts) {
19
20
  .filter((reference) => reference.projectId === binding.projectId);
20
21
  const matching = expectedLocalCommit === null
21
22
  ? []
22
- : projectPublications.filter((reference) => reference.localCommit === expectedLocalCommit);
23
+ : projectPublications.filter((reference) => reference.localCommit === expectedLocalCommit
24
+ || publicationAdoption(reference, expected.event, expectedLocalCommit, facts.events, facts.publications, facts.integrations ?? []).event !== null);
23
25
  const publication = bestPublication(matching.length > 0 ? matching : projectPublications);
24
- const coverage = projectCoverage(codeDelivery, expectedLocalCommit, publication);
26
+ const adoptionEvidence = publication === null ? null
27
+ : publicationAdoption(publication, expected.event, expectedLocalCommit, facts.events, facts.publications, facts.integrations ?? []);
28
+ const adoption = adoptionEvidence?.event ?? null;
29
+ const covers = expectedLocalCommit !== null && publication !== null
30
+ && (publication.localCommit === expectedLocalCommit || adoption !== null);
31
+ const headMatches = publication?.headCommit === undefined
32
+ || publication.headCommit === publication.localCommit;
33
+ const coverage = projectCoverage(codeDelivery, expectedLocalCommit, publication, covers, headMatches);
25
34
  const merged = codeDelivery === "none"
26
- || (publication !== null
27
- && expectedLocalCommit !== null
28
- && publication.localCommit === expectedLocalCommit
35
+ || (covers && headMatches
29
36
  && publication.state === "merged");
30
37
  const verified = codeDelivery === "none"
31
38
  || (merged && publication?.verification === "verified");
@@ -34,6 +41,8 @@ export function projectTaskRemoteDelivery(facts) {
34
41
  directory: binding.directory,
35
42
  baseCommit,
36
43
  expectedLocalCommit,
44
+ deliveryLocalCommit: covers ? publication.localCommit ?? null : null,
45
+ adoption,
37
46
  codeDelivery,
38
47
  publication,
39
48
  state: publication?.state ?? null,
@@ -42,7 +51,8 @@ export function projectTaskRemoteDelivery(facts) {
42
51
  coverage,
43
52
  merged,
44
53
  verified,
45
- reason: projectCoverageReason(codeDelivery, expectedLocalCommit, publication, coverage)
54
+ reason: projectCoverageReason(expectedLocalCommit, publication, coverage, adoption) + (coverage === "stale" && adoptionEvidence?.reason
55
+ ? ` ${adoptionEvidence.reason}` : "")
46
56
  };
47
57
  });
48
58
  const codeProjects = projects.filter((project) => project.codeDelivery !== "none");
@@ -55,8 +65,9 @@ export function projectTaskRemoteDelivery(facts) {
55
65
  : codeProjects.some((project) => project.coverage === "head-unavailable")
56
66
  ? "unavailable"
57
67
  : allMerged
58
- ? "merged"
59
- : mergedProjectCount > 0 ? "partial" : "pending";
68
+ ? allVerified ? "merged" : "unverified"
69
+ : mergedProjectCount > 0 ? "partial"
70
+ : codeProjects.some(p => p.state === "merged") ? "uncovered" : "pending";
60
71
  return {
61
72
  taskId: facts.task.id,
62
73
  source: expected.source,
@@ -111,7 +122,7 @@ function expectedDelivery(facts) {
111
122
  commits: new Map()
112
123
  };
113
124
  }
114
- function completionEvent(events, completedAt) {
125
+ export function completionEvent(events, completedAt) {
115
126
  const exact = latestEvent(events, "task.completed", (event) => (event.createdAt === completedAt));
116
127
  return exact ?? latestEvent(events, "task.completed", (event) => (event.createdAt <= completedAt));
117
128
  }
@@ -154,7 +165,7 @@ function candidateCommits(candidate) {
154
165
  commit.toLowerCase()
155
166
  ]));
156
167
  }
157
- function commitMap(value) {
168
+ export function commitMap(value) {
158
169
  const result = new Map();
159
170
  for (const entry of value?.split(",") ?? []) {
160
171
  const separator = entry.indexOf("@");
@@ -188,7 +199,7 @@ function publicationRank(reference) {
188
199
  return 2;
189
200
  return 1;
190
201
  }
191
- function projectCoverage(codeDelivery, expectedLocalCommit, publication) {
202
+ function projectCoverage(codeDelivery, expectedLocalCommit, publication, covers, headMatches) {
192
203
  if (codeDelivery === "none")
193
204
  return "not-applicable";
194
205
  if (expectedLocalCommit === null)
@@ -197,7 +208,9 @@ function projectCoverage(codeDelivery, expectedLocalCommit, publication) {
197
208
  return "missing";
198
209
  if (publication.localCommit === undefined)
199
210
  return "missing-local-commit";
200
- if (publication.localCommit !== expectedLocalCommit)
211
+ if (!headMatches)
212
+ return "head-mismatch";
213
+ if (!covers)
201
214
  return "stale";
202
215
  if (publication.state === "open")
203
216
  return "open";
@@ -205,7 +218,7 @@ function projectCoverage(codeDelivery, expectedLocalCommit, publication) {
205
218
  return "closed";
206
219
  return publication.verification === "verified" ? "verified" : "merged";
207
220
  }
208
- function projectCoverageReason(codeDelivery, expectedLocalCommit, publication, coverage) {
221
+ function projectCoverageReason(expectedLocalCommit, publication, coverage, adoption) {
209
222
  switch (coverage) {
210
223
  case "not-applicable":
211
224
  return "Task head equals the managed workspace base; no code delivery is required.";
@@ -216,15 +229,17 @@ function projectCoverageReason(codeDelivery, expectedLocalCommit, publication, c
216
229
  case "missing-local-commit":
217
230
  return `Publication ${publication.id} does not record a local commit.`;
218
231
  case "stale":
219
- return `Publication ${publication.id} covers ${publication.localCommit}, not the expected Task head ${expectedLocalCommit}.`;
232
+ return `Publication ${publication.id} is ${publication.state}, but its local candidate ${publication.localCommit} has no valid adoption covering accepted Task head ${expectedLocalCommit}.`;
233
+ case "head-mismatch":
234
+ return `Publication ${publication.id} is ${publication.state}, but observed remote head ${publication.headCommit} differs from local candidate ${publication.localCommit}; verification is invalid.`;
220
235
  case "open":
221
236
  return `Publication ${publication.id} covers the Task head but is still open.`;
222
237
  case "closed":
223
238
  return `Publication ${publication.id} covers the Task head but is closed without merge.`;
224
239
  case "merged":
225
- return `Publication ${publication.id} reports the exact Task head as merged.`;
240
+ return `Publication ${publication.id} reports a covering candidate as merged, but remote verification is still required.`;
226
241
  case "verified":
227
- return `Publication ${publication.id} verifies the exact Task head as merged.`;
242
+ return `Publication ${publication.id} verifies ${adoption === null ? "the exact Task head" : `candidate ${publication.localCommit} adopted by ${adoption.id}`} as merged.`;
228
243
  }
229
244
  }
230
245
  function compareCreated(left, right) {