zigmini 1.0.0-beta.2 → 1.1.0-beta.1
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.
- package/README.ko.md +36 -3
- package/README.md +36 -3
- package/assets/agent/gateway-request-rules.md +1 -1
- package/assets/agent/gateway-rules.md +6 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/assets/document-reader/document-reader +0 -0
- package/assets/document-reader/host-selector +0 -0
- package/assets/document-reader/manifest.json +27 -0
- package/docs/manual.ko.md +120 -11
- package/docs/manual.md +123 -12
- package/host/document-reader/DocumentRead.swift +370 -0
- package/host/document-reader/HostSelector.swift +192 -0
- package/host/document-reader/Protocol.swift +647 -0
- package/host/document-reader/main.swift +22 -0
- package/host/omo-bridge/agent-aside-tool.ts +119 -0
- package/host/omo-bridge/agent-aside.ts +522 -0
- package/host/omo-bridge/agent-collaboration-files.ts +282 -0
- package/host/omo-bridge/agent-collaboration.ts +385 -0
- package/host/omo-bridge/agent-control.ts +400 -28
- package/host/omo-bridge/agent-execution.sb +42 -0
- package/host/omo-bridge/agent-execution.ts +158 -0
- package/host/omo-bridge/agent-tool-adapters.ts +388 -0
- package/host/omo-bridge/agent-tool-permissions.ts +96 -0
- package/host/omo-bridge/aside-tabs.ts +41 -0
- package/host/omo-bridge/attachment-read.ts +100 -0
- package/host/omo-bridge/bridge.ts +10 -0
- package/host/omo-bridge/managed-call.ts +46 -0
- package/host/omo-bridge/task-control.ts +39 -5
- package/host/omo-check/patches/attachments-293/README.md +138 -0
- package/host/omo-check/patches/attachments-293/document-content.js +234 -0
- package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
- package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
- package/host/omo-check/patches/omo-5.1.51-chatgpt-terra.patch +21 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +44 -6
- package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
- package/package.json +5 -3
- package/src/agent/api.ts +345 -20
- package/src/agent/artifacts.ts +196 -0
- package/src/agent/aside-target-routes.ts +369 -0
- package/src/agent/aside-targets.ts +215 -0
- package/src/agent/collaboration-admission.ts +47 -0
- package/src/agent/collaboration-read.ts +117 -0
- package/src/agent/collaboration-session.ts +196 -0
- package/src/agent/collaboration-types.ts +258 -0
- package/src/agent/collaboration.ts +399 -0
- package/src/agent/definition.ts +65 -3
- package/src/agent/fields.ts +9 -3
- package/src/agent/file-core.ts +325 -0
- package/src/agent/files.ts +87 -8
- package/src/agent/list.ts +3 -3
- package/src/agent/managed-call.ts +144 -0
- package/src/agent/tool-catalog.ts +184 -0
- package/src/agent/tool-delivery.ts +359 -0
- package/src/agent/tool-permission-types.ts +81 -0
- package/src/agent/tool-permissions.ts +1366 -0
- package/src/attachments/api.ts +161 -0
- package/src/attachments/document-reader.ts +325 -0
- package/src/attachments/host-selector.ts +410 -0
- package/src/attachments/managed-binding.ts +83 -0
- package/src/attachments/prepare.ts +214 -0
- package/src/attachments/selector-port.ts +28 -0
- package/src/attachments/service.ts +729 -0
- package/src/attachments/store.ts +698 -0
- package/src/attachments/types.ts +123 -0
- package/src/cli/doctor.ts +30 -10
- package/src/decisions.ts +109 -10
- package/src/engine/apply-patch.ts +21 -0
- package/src/engine/compat.json +2 -1
- package/src/engine/install.ts +45 -4
- package/src/engine/sandbox-cell.ts +2 -0
- package/src/gc.ts +33 -225
- package/src/housekeeping/api.ts +163 -0
- package/src/housekeeping/audit.ts +61 -0
- package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
- package/src/housekeeping/collectors/agent-drafts.ts +43 -0
- package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
- package/src/housekeeping/collectors/agent-trash.ts +28 -0
- package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
- package/src/housekeeping/collectors/db-backup.ts +25 -0
- package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
- package/src/housekeeping/collectors/docker-image.ts +135 -0
- package/src/housekeeping/collectors/omo-version.ts +32 -0
- package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
- package/src/housekeeping/collectors/reconcile-work.ts +55 -0
- package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
- package/src/housekeeping/collectors/shared.ts +149 -0
- package/src/housekeeping/collectors/temp-dir.ts +28 -0
- package/src/housekeeping/collectors/update-file.ts +25 -0
- package/src/housekeeping/collectors/worktree.ts +193 -0
- package/src/housekeeping/engine.ts +125 -0
- package/src/housekeeping/reconcile/work.ts +130 -0
- package/src/housekeeping/registry.ts +63 -0
- package/src/housekeeping/trash.ts +59 -0
- package/src/housekeeping/types.ts +31 -0
- package/src/hub.ts +1095 -41
- package/src/main.ts +230 -31
- package/src/message-references.ts +14 -5
- package/src/profile-prompt.ts +13 -1
- package/src/profile.ts +13 -5
- package/src/protocol.ts +41 -6
- package/src/security-allow.ts +79 -2
- package/src/security-check.ts +1 -1
- package/src/store.ts +1946 -23
- package/src/test-crash.ts +20 -0
- package/src/usage.ts +18 -7
- package/src/work-watch.ts +55 -7
- package/src/work.ts +192 -3
- package/web/DESIGN.md +35 -1
- package/web/agents.d.ts +189 -12
- package/web/agents.js +898 -39
- package/web/app.js +2634 -210
- package/web/attachments.d.ts +91 -0
- package/web/attachments.js +422 -0
- package/web/collaboration.d.ts +87 -0
- package/web/collaboration.js +290 -0
- package/web/decisions.d.ts +39 -2
- package/web/decisions.js +354 -2
- package/web/gc.d.ts +26 -5
- package/web/gc.js +113 -13
- package/web/i18n/agents.d.ts +4 -0
- package/web/i18n/agents.js +200 -8
- package/web/i18n/attachments.js +78 -0
- package/web/i18n/decisions.d.ts +4 -0
- package/web/i18n/decisions.js +68 -0
- package/web/i18n/gc.d.ts +4 -0
- package/web/i18n/gc.js +184 -2
- package/web/i18n/main.js +80 -8
- package/web/i18n/tour.d.ts +4 -0
- package/web/i18n/tour.js +37 -0
- package/web/index.html +20 -3
- package/web/profile.d.ts +2 -2
- package/web/profile.js +16 -10
- package/web/style.css +342 -21
- package/web/tooltip.d.ts +2 -0
- package/web/tooltip.js +12 -0
- package/web/tour.d.ts +57 -0
- package/web/tour.js +450 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { writeSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/** The named process-stop boundaries of the Q-10 restart contract (test/contract/agent-collaboration-gaps-restart.contract.test.ts). */
|
|
4
|
+
export type TestCrashPoint =
|
|
5
|
+
| "collaboration.admission.before-commit"
|
|
6
|
+
| "collaboration.admission.after-commit"
|
|
7
|
+
| "collaboration.input.rpc-written"
|
|
8
|
+
| "collaboration.result.committed";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Test-only crash seam. Production never sets ZGW_TEST_CRASH_AT, so this returns at once there. When a restart contract
|
|
12
|
+
* test sets it to one point, the gateway writes `ZGW_TEST_CRASH_POINT <point>` synchronously to stderr and stops itself
|
|
13
|
+
* (SIGSTOP) at that exact boundary; the test then SIGKILLs the stopped process. Nothing runs between the boundary and the
|
|
14
|
+
* kill, so a pending transaction stays uncommitted and no later callback or socket frame is processed.
|
|
15
|
+
*/
|
|
16
|
+
export function testCrashPoint(point: TestCrashPoint): void {
|
|
17
|
+
if (process.env.ZGW_TEST_CRASH_AT !== point) return;
|
|
18
|
+
writeSync(2, `ZGW_TEST_CRASH_POINT ${point}\n`);
|
|
19
|
+
process.kill(process.pid, "SIGSTOP");
|
|
20
|
+
}
|
package/src/usage.ts
CHANGED
|
@@ -26,6 +26,7 @@ const USAGE_TEXT = {
|
|
|
26
26
|
reset: { en: " (resets {time})", ko: " (초기화 {time})" },
|
|
27
27
|
lastUsed: { en: " · Last used {time}", ko: " · 최근 사용 {time}" },
|
|
28
28
|
failed: { en: "Lookup failed ({detail})", ko: "조회 실패 ({detail})" },
|
|
29
|
+
limited: { en: " · Limit reached ({detail})", ko: " · 한도 도달 ({detail})" },
|
|
29
30
|
unknown: { en: "Unknown", ko: "알 수 없음" },
|
|
30
31
|
expired: { en: "Token expired (the engine will refresh it on next use)", ko: "토큰 만료 (엔진이 다음 사용 때 갱신)" },
|
|
31
32
|
unsupported: { en: "Usage API unavailable", ko: "사용량 API 없음" },
|
|
@@ -69,7 +70,8 @@ export interface UsageRow {
|
|
|
69
70
|
readonly provider: string;
|
|
70
71
|
readonly slot: string;
|
|
71
72
|
readonly pinned: boolean;
|
|
72
|
-
|
|
73
|
+
/** limited: HTTP 429; the windows are the last ones seen for this slot, as omo-usage keeps them. */
|
|
74
|
+
readonly status: "ok" | "limited" | "failed" | "expired" | "unsupported";
|
|
73
75
|
/** Why a lookup failed: an HTTP status or a fixed phrase, never a server message. */
|
|
74
76
|
readonly detail?: string;
|
|
75
77
|
readonly windows: readonly UsageWindow[];
|
|
@@ -280,7 +282,7 @@ async function lookup(account: Account, now: number, deps: UsageDeps): Promise<U
|
|
|
280
282
|
redirect: "error",
|
|
281
283
|
signal: AbortSignal.timeout(deps.timeoutMs ?? 10_000),
|
|
282
284
|
});
|
|
283
|
-
if (!response.ok) return { ...base, status: "failed", detail: say(USAGE_TEXT.http, { status: response.status }) };
|
|
285
|
+
if (!response.ok) return { ...base, status: response.status === 429 ? "limited" : "failed", detail: say(USAGE_TEXT.http, { status: response.status }) };
|
|
284
286
|
const windows = windowsOf(account.kind, await response.json().catch(() => undefined), now);
|
|
285
287
|
return windows.length > 0 ? { ...base, status: "ok", windows } : { ...base, status: "failed", detail: say(USAGE_TEXT.noUsage) };
|
|
286
288
|
} catch (error) {
|
|
@@ -289,11 +291,18 @@ async function lookup(account: Account, now: number, deps: UsageDeps): Promise<U
|
|
|
289
291
|
}
|
|
290
292
|
}
|
|
291
293
|
|
|
292
|
-
|
|
294
|
+
/** `last` holds each slot's newest windows (key provider/name); a 429 shows them, and without them the slot failed. */
|
|
295
|
+
export async function collectUsage(auth: unknown, poolState: unknown, now: number, deps: UsageDeps = {}, last = new Map<string, readonly UsageWindow[]>()): Promise<UsageRow[]> {
|
|
293
296
|
const used = lastUsed(poolState);
|
|
294
297
|
return Promise.all(readAccounts(auth).map(async (account) => {
|
|
295
|
-
const
|
|
296
|
-
|
|
298
|
+
const key = `${account.provider}/${account.name}`;
|
|
299
|
+
let row = await lookup(account, now, deps);
|
|
300
|
+
if (row.status === "ok") last.set(key, row.windows);
|
|
301
|
+
if (row.status === "limited") {
|
|
302
|
+
const windows = last.get(key);
|
|
303
|
+
row = windows ? { ...row, windows } : { ...row, status: "failed" };
|
|
304
|
+
}
|
|
305
|
+
const at = used.get(key);
|
|
297
306
|
return at === undefined ? row : { ...row, lastUsedAt: at };
|
|
298
307
|
}));
|
|
299
308
|
}
|
|
@@ -310,8 +319,9 @@ export function formatUsageReport(rows: readonly UsageRow[], timeZone?: string):
|
|
|
310
319
|
for (const row of rows) {
|
|
311
320
|
const name = say(USAGE_TEXT.account, { slot: row.slot, provider: row.provider, pinned: row.pinned ? say(USAGE_TEXT.pinned) : "" });
|
|
312
321
|
let body: string;
|
|
313
|
-
if (row.status === "ok") {
|
|
322
|
+
if (row.status === "ok" || row.status === "limited") {
|
|
314
323
|
body = row.windows.map((window) => `${say(USAGE_TEXT.remaining, { label: window.label === "기간" ? say(USAGE_TEXT.period) : window.label, percent: window.remaining })}${window.resetsAt === null ? "" : say(USAGE_TEXT.reset, { time: stamp(window.resetsAt, timeZone) })}`).join(" · ");
|
|
324
|
+
if (row.status === "limited") body += say(USAGE_TEXT.limited, { detail: row.detail ?? say(USAGE_TEXT.unknown) });
|
|
315
325
|
} else if (row.status === "failed") body = say(USAGE_TEXT.failed, { detail: row.detail ?? say(USAGE_TEXT.unknown) });
|
|
316
326
|
else body = say(USAGE_TEXT[row.status]);
|
|
317
327
|
const recent = row.lastUsedAt === undefined ? "" : say(USAGE_TEXT.lastUsed, { time: stamp(row.lastUsedAt, timeZone) });
|
|
@@ -340,13 +350,14 @@ export function createUsageReport(deps: UsageDeps & {
|
|
|
340
350
|
now?: () => number;
|
|
341
351
|
timeZone?: string;
|
|
342
352
|
}): () => Promise<string> {
|
|
353
|
+
const last = new Map<string, readonly UsageWindow[]>();
|
|
343
354
|
return async () => {
|
|
344
355
|
if (deps.inContainer) return `${say(USAGE_TEXT.title)}\n${say(USAGE_UNAVAILABLE)}`;
|
|
345
356
|
const auth = readJson(join(deps.dir, "auth.json"));
|
|
346
357
|
if ("missing" in auth) return `${say(USAGE_TEXT.title)}\n${say(USAGE_TEXT.emptyAuth)}`;
|
|
347
358
|
if ("broken" in auth) return `${say(USAGE_TEXT.title)}\n${say(USAGE_TEXT.brokenAuth)}`;
|
|
348
359
|
const pool = readJson(join(deps.dir, "credential-pool-state.json"));
|
|
349
|
-
const rows = await collectUsage(auth.value, "value" in pool ? pool.value : null, (deps.now ?? Date.now)(), deps);
|
|
360
|
+
const rows = await collectUsage(auth.value, "value" in pool ? pool.value : null, (deps.now ?? Date.now)(), deps, last);
|
|
350
361
|
return formatUsageReport(rows, deps.timeZone);
|
|
351
362
|
};
|
|
352
363
|
}
|
package/src/work-watch.ts
CHANGED
|
@@ -116,6 +116,31 @@ const NOTE_QUIET_IN_TOOL: Text = { en: "No activity for over {duration} while a
|
|
|
116
116
|
const NOTE_CANCEL_STOPPED: Text = { en: "The run stopped after the cancel command", ko: "취소 명령 뒤 실행이 멈췄습니다" };
|
|
117
117
|
const NOTE_UNDELIVERED: Text = { en: "The command was not confirmed as received", ko: "명령 수신을 확인하지 못했습니다" };
|
|
118
118
|
const NOTE_DELIVERY_CHECKING: Text = { en: "Confirming that the command was received", ko: "명령 수신을 확인하는 중입니다" };
|
|
119
|
+
const NOTE_ADMISSION_FAILED: Text = { en: "The command could not be delivered to the work session", ko: "명령을 작업 세션에 전달하지 못했습니다" };
|
|
120
|
+
/** Closures by housekeeping reconcile (bug 282): the stored reason says why, the note says it on the card. */
|
|
121
|
+
const RECONCILE_NOTES: Readonly<Record<string, Text>> = {
|
|
122
|
+
reconcile_session_closed: { en: "Closed because the work session ended without a report", ko: "작업 세션이 보고 없이 닫혀 실패로 정리했습니다" },
|
|
123
|
+
reconcile_cancel_unreachable: { en: "Cancelled because the cancel command could not reach a work that was not running", ko: "실행 중이 아닌 작업에 취소 명령이 닿지 못해 취소로 정리했습니다" },
|
|
124
|
+
};
|
|
125
|
+
/** Confirm alerts from housekeeping reconcile (bug 282): main reads the summary and asks the operator; the stored reason stays the key. */
|
|
126
|
+
const CONFIRM_TAIL: Text = {
|
|
127
|
+
en: "Ask the operator whether to cancel this work or send a new instruction. If the new instruction returns prior_command_unknown, send the cancel first.",
|
|
128
|
+
ko: "운영자에게 이 작업을 취소할지, 새 지시를 보낼지 물어봐 주세요. 새 지시가 prior_command_unknown으로 거부되면 취소를 먼저 보내 주세요.",
|
|
129
|
+
};
|
|
130
|
+
export const RECONCILE_CONFIRM_SUMMARIES: Readonly<Record<string, Text>> = {
|
|
131
|
+
reconcile_confirm_undelivered: {
|
|
132
|
+
en: `The last command was not confirmed as received by the work session. ${CONFIRM_TAIL.en}`,
|
|
133
|
+
ko: `마지막 명령을 작업 세션이 받았는지 확인하지 못했습니다. ${CONFIRM_TAIL.ko}`,
|
|
134
|
+
},
|
|
135
|
+
reconcile_confirm_no_report: {
|
|
136
|
+
en: `The work session stopped after receiving the command, but it sent no report. ${CONFIRM_TAIL.en}`,
|
|
137
|
+
ko: `작업 세션이 명령을 받은 뒤 보고 없이 멈췄습니다. ${CONFIRM_TAIL.ko}`,
|
|
138
|
+
},
|
|
139
|
+
reconcile_confirm_unreachable: {
|
|
140
|
+
en: `The last command could not reach the work session. ${CONFIRM_TAIL.en}`,
|
|
141
|
+
ko: `마지막 명령이 작업 세션에 닿지 못했습니다. ${CONFIRM_TAIL.ko}`,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
119
144
|
const NOTE_REPORT: Text = { en: "Report: {summary}", ko: "보고: {summary}" };
|
|
120
145
|
const NOTE_REQUEST_UNANSWERED: Text = { en: "The status report request got no response", ko: "상태 보고 요청에 응답이 없습니다" };
|
|
121
146
|
const NOTE_REQUESTED: Text = { en: "Stopped without a report, so a status report was requested", ko: "보고 없이 멈춰 상태 보고를 요청했습니다" };
|
|
@@ -164,7 +189,10 @@ function storeState(work: WorkItem): WatchState {
|
|
|
164
189
|
export function judge(input: JudgeInput, config: WatchConfig = DEFAULT_WATCH): Verdict {
|
|
165
190
|
const { work, commands, updates, live, now } = input;
|
|
166
191
|
const base = Date.parse(work.updatedAt);
|
|
167
|
-
if (TERMINAL.has(work.status))
|
|
192
|
+
if (TERMINAL.has(work.status)) {
|
|
193
|
+
const reconciled = work.statusReason?.startsWith("reconcile_") ? RECONCILE_NOTES[work.statusReason] : undefined;
|
|
194
|
+
return { state: work.status as WatchState, reason: "reported", ...(reconciled ? { note: say(reconciled) } : {}), since: base, next: Infinity };
|
|
195
|
+
}
|
|
168
196
|
// While OMO is away nothing new can be observed: show the record, judge nothing.
|
|
169
197
|
if (!input.ready) return { state: storeState(work), reason: "omo_not_ready", since: base, next: Infinity };
|
|
170
198
|
if (input.openDecisions > 0 || work.status === "waiting_input") {
|
|
@@ -183,6 +211,11 @@ export function judge(input: JudgeInput, config: WatchConfig = DEFAULT_WATCH): V
|
|
|
183
211
|
state: "waiting", waitingFor: "children", reason: "children_running",
|
|
184
212
|
note: say(NOTE_CHILDREN, { count: children }), since: waitSince, next,
|
|
185
213
|
});
|
|
214
|
+
// Bug 282: an admission failure leaves the command pending with its error and nothing retries it, so it is not
|
|
215
|
+
// being sent; this holds with or without runtime evidence (right after a restart there is none).
|
|
216
|
+
if (command.delivery === "pending" && command.error !== null) {
|
|
217
|
+
return { state: "unknown", reason: "command_admission_failed", note: say(NOTE_ADMISSION_FAILED), since: base, next: Infinity };
|
|
218
|
+
}
|
|
186
219
|
// Without confirmed runtime evidence (no event, no reconciliation yet) the watcher shows the record and never acts.
|
|
187
220
|
if (live.streaming === null) return { state: storeState(work), reason: "runtime_unknown", since: base, next: Infinity };
|
|
188
221
|
|
|
@@ -307,6 +340,11 @@ export class WorkWatcher {
|
|
|
307
340
|
return live;
|
|
308
341
|
}
|
|
309
342
|
|
|
343
|
+
/** Active child tasks of a work from its live signals; null when none were seen since OMO came back (unknown). */
|
|
344
|
+
children(workId: string): number | null {
|
|
345
|
+
return this.lives.get(workId)?.children?.active ?? null;
|
|
346
|
+
}
|
|
347
|
+
|
|
310
348
|
/** OMO is away: forget every live signal; judge and act on nothing. */
|
|
311
349
|
pause(): void {
|
|
312
350
|
if (this.stopped) return;
|
|
@@ -412,18 +450,28 @@ export class WorkWatcher {
|
|
|
412
450
|
if (event.success !== false || event.finalError === "Retry cancelled") return false;
|
|
413
451
|
live.lastError = { kind: "retry_exhausted", message: typeof event.finalError === "string" ? event.finalError : say(ERROR_LABELS.retry_exhausted), at: now };
|
|
414
452
|
return true;
|
|
415
|
-
case "extension_event":
|
|
453
|
+
case "extension_event":
|
|
416
454
|
if (event.name !== "omo.task.updated" || !record(event.data) || !Array.isArray(event.data.tasks)) return false;
|
|
417
|
-
|
|
418
|
-
const before = live.children?.active;
|
|
419
|
-
live.children = { active, at: now };
|
|
420
|
-
return before !== active;
|
|
421
|
-
}
|
|
455
|
+
return this.setChildren(live, event.data.tasks, now);
|
|
422
456
|
default:
|
|
423
457
|
return false;
|
|
424
458
|
}
|
|
425
459
|
}
|
|
426
460
|
|
|
461
|
+
/** Counts the running sub-tasks of a task list; true when the count changed. */
|
|
462
|
+
private setChildren(live: Live, tasks: readonly unknown[], now: number): boolean {
|
|
463
|
+
const active = tasks.filter((task) => record(task) && (task.status === "pending" || task.status === "running")).length;
|
|
464
|
+
const before = live.children?.active;
|
|
465
|
+
live.children = { active, at: now };
|
|
466
|
+
return before !== active;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** OMO's child-task list answered at a bind (bug 282, F3 F-1): sets the count like `omo.task.updated`, not activity. */
|
|
470
|
+
childTasks(workId: string, tasks: readonly unknown[]): void {
|
|
471
|
+
if (!this.observing) return;
|
|
472
|
+
if (this.setChildren(this.live(workId), tasks, this.clock())) this.schedule();
|
|
473
|
+
}
|
|
474
|
+
|
|
427
475
|
/** A reconciliation read the native isStreaming. */
|
|
428
476
|
runtime(workId: string, streaming: boolean): void {
|
|
429
477
|
if (!this.observing) return;
|
package/src/work.ts
CHANGED
|
@@ -5,6 +5,11 @@ import { OmoCommandError, type OmoClient, type OmoEvent, type OmoStatus } from "
|
|
|
5
5
|
import { DecisionStoreError, Store, WorkStoreError, type LedgerState, type MainInput, type StoredEvent, type WorkCommand, type WorkItem, type WorkReport, type WorkUpdateKind } from "./store.ts";
|
|
6
6
|
import { nativeReplyEntry, type NativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
|
|
7
7
|
import { notificationHistory } from "./notification-history.ts";
|
|
8
|
+
import type { AuditLine, AuditSink } from "./housekeeping/audit.ts";
|
|
9
|
+
import { reconcileWorkId, type ReconcileItem } from "./housekeeping/collectors/reconcile-work.ts";
|
|
10
|
+
import { say } from "./server-text.ts";
|
|
11
|
+
import { RECONCILE_CONFIRM_SUMMARIES } from "./work-watch.ts";
|
|
12
|
+
import { classify, retryableRefusal, type ReconcileEvidence, type ReconcileVerdict } from "./housekeeping/reconcile/work.ts";
|
|
8
13
|
|
|
9
14
|
const ACK_TIMEOUT_MS = 30_000;
|
|
10
15
|
const PREFIX = "ZGW_WORK_COMMAND ";
|
|
@@ -15,7 +20,7 @@ type Session = { sessionId: string; sessionPath?: string; status: string };
|
|
|
15
20
|
type State = {
|
|
16
21
|
isStreaming?: boolean; steering?: string[]; followUp?: string[];
|
|
17
22
|
ordered?: { text: string; mode?: string; clientInputId?: string }[];
|
|
18
|
-
pendingQuestions?: OmoEvent[]; atomicQueuedResume?: boolean; queueEpoch?: string;
|
|
23
|
+
pendingQuestions?: OmoEvent[]; atomicQueuedResume?: boolean; queueEpoch?: string; sessionId?: string;
|
|
19
24
|
};
|
|
20
25
|
type Entry = NativeReplyEntry & { id: string; type: string; customType?: string; data?: unknown };
|
|
21
26
|
export interface WorkSessionOwner {
|
|
@@ -25,6 +30,8 @@ export interface WorkSessionOwner {
|
|
|
25
30
|
readonly sessionPath: string;
|
|
26
31
|
}
|
|
27
32
|
type Binding = { sessionId: string; generation: number; questions: Set<string>; owner: WorkSessionOwner };
|
|
33
|
+
/** The last get_state of a bound work session: native isStreaming, when it was read, and which commands it showed. */
|
|
34
|
+
type StateRead = { isStreaming: boolean; stateReadAt: string; matched: ReadonlySet<string> };
|
|
28
35
|
export interface WorkRequest {
|
|
29
36
|
operation: "start" | "send" | "list" | "get" | "cancel" | "report" | "publish" | "precomment";
|
|
30
37
|
requestKey: string;
|
|
@@ -44,6 +51,8 @@ export interface WorkRouterOptions {
|
|
|
44
51
|
/** Synchronous: release this exact old binding; never infer an owner from a later event. */
|
|
45
52
|
onUnbound?(owner: WorkSessionOwner, reason: "parked" | "closed" | "disconnected" | "stopped"): void;
|
|
46
53
|
onUiEvent?(owner: WorkSessionOwner, event: OmoEvent): void;
|
|
54
|
+
/** Bug 236: OMO queued an instruction (not a watcher status request) behind the work's running turn. */
|
|
55
|
+
onInstructionQueued?(owner: WorkSessionOwner): void;
|
|
47
56
|
onDecisionsChanged?(): void;
|
|
48
57
|
onSettled?(owner: WorkSessionOwner): void;
|
|
49
58
|
/** Every event of a currently bound work session, for the work watcher's live signals. */
|
|
@@ -54,6 +63,14 @@ export interface WorkRouterOptions {
|
|
|
54
63
|
mainLedger?(conv: string, sessionId: string): Promise<LedgerState | null>;
|
|
55
64
|
/** Production uses the locked 30s bound. Tests may exercise the timeout at a shorter bound. */
|
|
56
65
|
ackTimeoutMs?: number;
|
|
66
|
+
/** Active child tasks of a work, from the watcher's live signals; null or absent = unknown, which never allows a closure. */
|
|
67
|
+
children?(workId: string): number | null;
|
|
68
|
+
/** OMO's child-task list of a work session (`omo.task.list`, the `omo.task.updated` tasks), asked at every bind. */
|
|
69
|
+
onChildTasks?(workId: string, tasks: readonly unknown[]): void;
|
|
70
|
+
/** Housekeeping audit log: one line per reconcile closure (or refused closure) and per new confirm alert. */
|
|
71
|
+
audit?: AuditSink;
|
|
72
|
+
/** The reconcile items changed; housekeeping rescans its reconcile-work collector. */
|
|
73
|
+
onReconciled?(): void;
|
|
57
74
|
}
|
|
58
75
|
class RoutingError extends Error {
|
|
59
76
|
constructor(readonly code: string, readonly status = 409) { super(code); }
|
|
@@ -114,6 +131,12 @@ export class WorkRouter {
|
|
|
114
131
|
private readonly reconciliations = new Map<string, Promise<void>>();
|
|
115
132
|
private readonly scheduled = new Set<string>();
|
|
116
133
|
private readonly pendingUi = new Map<string, OmoEvent[]>();
|
|
134
|
+
// Reconcile evidence of the current OMO generation (bug 282); cleared whenever the generation changes.
|
|
135
|
+
private readonly refused = new Map<string, { error: string; retryable: boolean }>();
|
|
136
|
+
private readonly admissionFailed = new Map<string, Set<string>>();
|
|
137
|
+
private readonly lifecycle = new Map<string, { kind: "closed"; reason: string; at: string }>();
|
|
138
|
+
private readonly lastState = new Map<string, StateRead>();
|
|
139
|
+
private readonly reconciled = new Map<string, ReconcileItem>();
|
|
117
140
|
private wholeReads: Promise<unknown> = Promise.resolve();
|
|
118
141
|
private generation = 0;
|
|
119
142
|
private stopped = false;
|
|
@@ -151,6 +174,8 @@ export class WorkRouter {
|
|
|
151
174
|
const binding = this.bindings.get(workId);
|
|
152
175
|
if (!binding) return;
|
|
153
176
|
this.bindings.delete(workId);
|
|
177
|
+
// A parked or closed session's last state is not evidence about the work any more.
|
|
178
|
+
this.lastState.delete(workId);
|
|
154
179
|
this.pendingUi.delete(binding.sessionId);
|
|
155
180
|
this.options.onUnbound?.(binding.owner, reason);
|
|
156
181
|
}
|
|
@@ -402,7 +427,11 @@ export class WorkRouter {
|
|
|
402
427
|
if (!this.ready) return;
|
|
403
428
|
const previous = this.flights.get(workId);
|
|
404
429
|
if (previous) return previous;
|
|
405
|
-
const
|
|
430
|
+
const generation = this.generation;
|
|
431
|
+
// T1: reconcile runs once the flight is gone, so it sees this generation's settled dispatch.
|
|
432
|
+
const flight: Promise<void> = this.transmit(workId)
|
|
433
|
+
.finally(() => { if (this.flights.get(workId) === flight) this.flights.delete(workId); })
|
|
434
|
+
.finally(() => this.reconcileAfterDispatch(workId, generation));
|
|
406
435
|
this.flights.set(workId, flight);
|
|
407
436
|
return flight;
|
|
408
437
|
}
|
|
@@ -420,6 +449,11 @@ export class WorkRouter {
|
|
|
420
449
|
try { binding = await this.ensure(this.work(workId)); }
|
|
421
450
|
catch (error) {
|
|
422
451
|
if (!this.stopped) {
|
|
452
|
+
if (generation === this.generation) {
|
|
453
|
+
this.refuse(workId, error);
|
|
454
|
+
const failed = this.admissionFailed.get(workId) ?? new Set<string>();
|
|
455
|
+
this.admissionFailed.set(workId, failed.add(command.id));
|
|
456
|
+
}
|
|
423
457
|
this.store.setWorkCommandDelivery(command.id, "pending", { error: errorText(error) });
|
|
424
458
|
this.notify(workId);
|
|
425
459
|
}
|
|
@@ -445,6 +479,9 @@ export class WorkRouter {
|
|
|
445
479
|
this.store.setWorkCommandDelivery(command.id, "accepted", { disposition: interrupt ? "turn_aborted_cleanup_unconfirmed" : typeof data?.disposition === "string" ? data.disposition : "prompt_ack" });
|
|
446
480
|
this.notify(workId);
|
|
447
481
|
if (!interrupt && data?.disposition === "queued" && this.current(workId, binding)) {
|
|
482
|
+
if (command.kind === "instruct" && !(record(command.payload) && command.payload.watcher === "status_request")) {
|
|
483
|
+
this.options.onInstructionQueued?.(binding.owner);
|
|
484
|
+
}
|
|
448
485
|
void this.omo.command<State>("get_state", { sessionId: binding.sessionId })
|
|
449
486
|
.then((state) => { if (this.current(workId, binding)) this.resumeQueuedCommand(workId, binding, state); })
|
|
450
487
|
.catch((error) => this.fail(error));
|
|
@@ -492,10 +529,14 @@ export class WorkRouter {
|
|
|
492
529
|
};
|
|
493
530
|
const binding: Binding = { sessionId: opened.sessionId, generation, questions: new Set(), owner };
|
|
494
531
|
this.bindings.set(work.id, binding);
|
|
532
|
+
this.refused.delete(work.id);
|
|
533
|
+
this.admissionFailed.delete(work.id);
|
|
534
|
+
this.lifecycle.delete(work.id);
|
|
495
535
|
this.options.onBound?.(owner, opened.state?.pendingQuestions ?? []);
|
|
496
536
|
const replay = this.pendingUi.get(opened.sessionId) ?? [];
|
|
497
537
|
this.pendingUi.delete(opened.sessionId);
|
|
498
538
|
for (const event of replay) this.onEvent(event);
|
|
539
|
+
this.learnChildren(work.id, binding, opened.state?.sessionId);
|
|
499
540
|
return binding;
|
|
500
541
|
}).finally(() => {
|
|
501
542
|
if (this.opening.get(work.id) === promise) this.opening.delete(work.id);
|
|
@@ -505,6 +546,25 @@ export class WorkRouter {
|
|
|
505
546
|
return promise;
|
|
506
547
|
}
|
|
507
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Bug 282 (F3 F-1): a bind starts with the child-task count unknown, and an idle work without sub-tasks sends no later
|
|
551
|
+
* `omo.task.updated` (its snapshot at session start arrives before the open ACK, while the session is unbound). So every
|
|
552
|
+
* bind asks OMO once, by the durable session id. An answer without a task list (an OMO without `omo.task.list`,
|
|
553
|
+
* not_found) or no answer leaves the count unknown, which keeps closures off. Known children re-run the T1 evaluation.
|
|
554
|
+
*/
|
|
555
|
+
private learnChildren(workId: string, binding: Binding, durableSessionId: string | undefined): void {
|
|
556
|
+
if (typeof durableSessionId !== "string") return;
|
|
557
|
+
void this.omo.command<unknown>("extension_request", { sessionId: binding.sessionId, name: "omo.task.list", data: { parent_session_id: durableSessionId } })
|
|
558
|
+
.then((answer) => {
|
|
559
|
+
if (!this.current(workId, binding) || !record(answer) || !Array.isArray(answer.tasks)) return;
|
|
560
|
+
this.options.onChildTasks?.(workId, answer.tasks);
|
|
561
|
+
this.schedule(workId);
|
|
562
|
+
}, (error: unknown) => {
|
|
563
|
+
// A refusal (no such request on this OMO) is OMO's answer that it cannot tell: the count stays unknown.
|
|
564
|
+
if (!(error instanceof OmoCommandError)) this.fail(error);
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
508
568
|
/** The one unbounded response this router asks for: one whole-session read in flight at a time, across works. */
|
|
509
569
|
private wholeSession(sessionId: string, live: () => boolean): Promise<Entry[] | null> {
|
|
510
570
|
const read = this.wholeReads.then(() => live()
|
|
@@ -605,6 +665,7 @@ export class WorkRouter {
|
|
|
605
665
|
}),
|
|
606
666
|
this.omo.command<State>("get_state", { sessionId: binding.sessionId }),
|
|
607
667
|
]);
|
|
668
|
+
const stateReadAt = new Date().toISOString();
|
|
608
669
|
if (!entries || !this.current(workId, binding)) return;
|
|
609
670
|
if (replyRevision !== undefined) this.options.replies?.observe({ sessionPath: work.sessionPath, conv: work.ownerConv, workId, title: work.title }, entries, replyRevision);
|
|
610
671
|
if (typeof state.isStreaming === "boolean") this.options.onRuntime?.(workId, state.isStreaming);
|
|
@@ -615,6 +676,10 @@ export class WorkRouter {
|
|
|
615
676
|
}
|
|
616
677
|
}
|
|
617
678
|
const evidence = [...queue(state), ...entries.filter((e) => e.type === "message" && e.message?.role === "user").map((e) => messageText(e.message?.content))];
|
|
679
|
+
if (typeof state.isStreaming === "boolean") {
|
|
680
|
+
const matched = new Set(this.store.listWorkCommands(workId).filter((command) => evidence.some((value) => this.matches(value, command))).map((command) => command.id));
|
|
681
|
+
this.lastState.set(workId, { isStreaming: state.isStreaming, stateReadAt, matched });
|
|
682
|
+
}
|
|
618
683
|
for (const command of this.store.listWorkCommands(workId)) {
|
|
619
684
|
if (["pending", "sending", "unknown"].includes(command.delivery) && evidence.some((value) => this.matches(value, command))) this.store.setWorkCommandDelivery(command.id, "accepted", { disposition: "command_envelope" });
|
|
620
685
|
}
|
|
@@ -700,12 +765,19 @@ export class WorkRouter {
|
|
|
700
765
|
this.store.observeWork(workId, "unknown", "session_closed");
|
|
701
766
|
this.notify(workId);
|
|
702
767
|
}
|
|
768
|
+
// Parked (handoff_parked) is not failure, and a generation slot removal is not this session's loss.
|
|
769
|
+
if (event.type === "session_closed" && typeof event.reason === "string" && event.reason !== "handoff_parked" &&
|
|
770
|
+
event.reason !== "generation_ended" && event.synthetic !== true) {
|
|
771
|
+
this.lifecycle.set(workId, { kind: "closed", reason: event.reason, at: new Date().toISOString() });
|
|
772
|
+
}
|
|
773
|
+
this.schedule(workId); // T4
|
|
703
774
|
}
|
|
704
775
|
// senpi publishes agent_settled (reason session_closed) before it seals a session closed or
|
|
705
776
|
// parked mid-turn; that session can no longer be read, and its lifecycle event follows.
|
|
706
777
|
else if (event.type === "agent_settled" && event.reason !== "session_closed") {
|
|
707
778
|
void this.reconcile(workId).then(() => {
|
|
708
779
|
if (this.current(workId, binding)) this.options.onSettled?.(binding.owner);
|
|
780
|
+
this.schedule(workId); // T3
|
|
709
781
|
}).catch((error) => this.fail(error));
|
|
710
782
|
}
|
|
711
783
|
return true;
|
|
@@ -720,6 +792,7 @@ export class WorkRouter {
|
|
|
720
792
|
const attached = new Set(this.bindings.keys());
|
|
721
793
|
this.generation++;
|
|
722
794
|
for (const workId of [...this.bindings.keys()]) this.unbind(workId, "disconnected");
|
|
795
|
+
this.clearEvidence();
|
|
723
796
|
this.opening.clear();
|
|
724
797
|
this.pendingUi.clear();
|
|
725
798
|
const affected: string[] = [];
|
|
@@ -768,7 +841,14 @@ export class WorkRouter {
|
|
|
768
841
|
}
|
|
769
842
|
continue;
|
|
770
843
|
}
|
|
771
|
-
|
|
844
|
+
try {
|
|
845
|
+
await this.reconcile(work.id);
|
|
846
|
+
} catch (error) {
|
|
847
|
+
// T2: the refusal is reconcile evidence; dispatch still runs so T1 evaluates it.
|
|
848
|
+
if (generation === this.generation) this.refuse(work.id, error);
|
|
849
|
+
this.schedule(work.id);
|
|
850
|
+
throw error;
|
|
851
|
+
}
|
|
772
852
|
this.schedule(work.id);
|
|
773
853
|
} catch (error) { this.fail(error); }
|
|
774
854
|
}
|
|
@@ -780,6 +860,115 @@ export class WorkRouter {
|
|
|
780
860
|
this.stopped = true;
|
|
781
861
|
this.generation++;
|
|
782
862
|
for (const workId of [...this.bindings.keys()]) this.unbind(workId, "stopped");
|
|
863
|
+
this.clearEvidence();
|
|
783
864
|
this.pendingUi.clear();
|
|
784
865
|
}
|
|
866
|
+
|
|
867
|
+
/** The router's current reconcile closures and pending confirms, for the housekeeping reconcile-work collector. */
|
|
868
|
+
reconcileItems(): ReconcileItem[] {
|
|
869
|
+
return [...this.reconciled.values()].filter((item) => this.stillClosed(item) ||
|
|
870
|
+
(item.kind === "confirm" && !["completed", "failed", "cancelled"].includes(this.store.getWork(item.workId)?.status ?? "completed")));
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** A closure stays listed until the work is reopened (a new instruct) or otherwise leaves that closed state. */
|
|
874
|
+
private stillClosed(item: ReconcileItem): boolean {
|
|
875
|
+
return item.kind === "close" && this.store.getWork(item.workId)?.statusReason === item.reason;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
private clearEvidence(): void {
|
|
879
|
+
this.refused.clear();
|
|
880
|
+
this.admissionFailed.clear();
|
|
881
|
+
this.lifecycle.clear();
|
|
882
|
+
this.lastState.clear();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
private refuse(workId: string, error: unknown): void {
|
|
886
|
+
const typed = error instanceof OmoCommandError;
|
|
887
|
+
const data = typed && record(error.errorData) ? error.errorData : null;
|
|
888
|
+
const retryAfterMs = typeof data?.retry_after_ms === "number" ? data.retry_after_ms : null;
|
|
889
|
+
this.refused.set(workId, { error: errorText(error), retryable: retryableRefusal(typed ? error.code : null, retryAfterMs) });
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** Evidence for the classifier, exactly as the field comments of ReconcileEvidence define it. */
|
|
893
|
+
private collect(workId: string, generation: number): ReconcileEvidence | null {
|
|
894
|
+
const work = this.store.getWork(workId);
|
|
895
|
+
if (!work) return null;
|
|
896
|
+
const commands = this.store.listWorkCommands(workId);
|
|
897
|
+
const latest = commands.at(-1);
|
|
898
|
+
const state = this.lastState.get(workId);
|
|
899
|
+
const inSession = (id: string) => state ? state.matched.has(id) : null;
|
|
900
|
+
const reported = new Set(this.store.listWorkUpdates(workId)
|
|
901
|
+
.filter((u) => reportKinds.has(u.kind) && !(record(u.details) && u.details.source === "watcher")).map((u) => u.commandSeq));
|
|
902
|
+
const blocked = latest?.delivery === "pending" ? commands.filter((c) => c.seq < latest.seq && c.delivery === "unknown").at(-1) : undefined;
|
|
903
|
+
const refused = this.refused.get(workId);
|
|
904
|
+
return {
|
|
905
|
+
status: work.status,
|
|
906
|
+
latest: latest ? {
|
|
907
|
+
id: latest.id, seq: latest.seq, kind: latest.kind, delivery: latest.delivery, error: latest.error, createdAt: latest.createdAt, acceptedAt: latest.acceptedAt,
|
|
908
|
+
admissionFailed: this.admissionFailed.get(workId)?.has(latest.id) ?? false, inSession: inSession(latest.id), reported: reported.has(latest.seq),
|
|
909
|
+
} : null,
|
|
910
|
+
blockedBy: blocked ? { id: blocked.id, seq: blocked.seq, delivery: blocked.delivery, inSession: inSession(blocked.id) } : null,
|
|
911
|
+
omoReady: this.ready,
|
|
912
|
+
evaluatedAfterDispatch: generation === this.generation,
|
|
913
|
+
session: refused ? { kind: "refused", retryable: refused.retryable, error: refused.error }
|
|
914
|
+
: state ? { kind: "readable", isStreaming: state.isStreaming, stateReadAt: state.stateReadAt } : { kind: "unread" },
|
|
915
|
+
lifecycle: this.lifecycle.get(workId) ?? null,
|
|
916
|
+
children: this.options.children?.(workId) ?? null,
|
|
917
|
+
openDecisions: this.store.unresolvedWorkDecisions(workId),
|
|
918
|
+
inFlight: this.flights.has(workId),
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** T1. Never sends a command; a cancel that is not accepted gets one fresh read first. */
|
|
923
|
+
private async reconcileAfterDispatch(workId: string, generation: number): Promise<void> {
|
|
924
|
+
try {
|
|
925
|
+
if (!this.ready || generation !== this.generation) return;
|
|
926
|
+
const latest = this.store.listWorkCommands(workId).at(-1);
|
|
927
|
+
const open = !["completed", "failed", "cancelled"].includes(this.store.getWork(workId)?.status ?? "completed");
|
|
928
|
+
if (open && latest?.kind === "cancel" && latest.delivery !== "accepted") {
|
|
929
|
+
try { await this.reconcile(workId); } catch (error) { if (generation === this.generation) this.refuse(workId, error); }
|
|
930
|
+
}
|
|
931
|
+
const evidence = this.collect(workId, generation);
|
|
932
|
+
if (evidence) this.applyVerdict(workId, evidence, classify(evidence));
|
|
933
|
+
} catch (error) { this.fail(error); }
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
private applyVerdict(workId: string, evidence: ReconcileEvidence, verdict: ReconcileVerdict): void {
|
|
937
|
+
const latest = evidence.latest;
|
|
938
|
+
if (verdict.kind === "keep" || !latest) {
|
|
939
|
+
const held = this.reconciled.get(workId);
|
|
940
|
+
if (held && !this.stillClosed(held)) {
|
|
941
|
+
this.reconciled.delete(workId);
|
|
942
|
+
this.options.onReconciled?.();
|
|
943
|
+
}
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
const id = reconcileWorkId({ reason: verdict.reason, commandId: latest.id });
|
|
947
|
+
const item = (status: ReconcileItem["status"]): ReconcileItem => ({ workId, title: this.work(workId).title, kind: verdict.kind, reason: verdict.reason,
|
|
948
|
+
status, commandId: latest.id, commandSeq: latest.seq, evidence: verdict.evidence, at: new Date().toISOString() });
|
|
949
|
+
if (verdict.kind === "close") {
|
|
950
|
+
const closed = this.store.closeByReconcile(workId, latest.seq, verdict.status, verdict.reason, verdict.evidence);
|
|
951
|
+
this.audit({ id, target: workId, verdict: "act", evidence: verdict.evidence, method: "state", ok: closed !== null, ...(closed ? {} : { error: "close_refused" }) });
|
|
952
|
+
if (!closed) return;
|
|
953
|
+
this.reconciled.set(workId, item(closed.status));
|
|
954
|
+
this.notify(workId);
|
|
955
|
+
this.options.onReconciled?.();
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const alert = this.store.recordWatchAlert(workId, { sourceKey: id, commandSeq: latest.seq,
|
|
959
|
+
summary: RECONCILE_CONFIRM_SUMMARIES[verdict.reason] ? say(RECONCILE_CONFIRM_SUMMARIES[verdict.reason]!) : verdict.reason,
|
|
960
|
+
details: { ...verdict.evidence, source: "watcher", reconcile: true, requiresUserAction: true } });
|
|
961
|
+
this.reconciled.set(workId, item(evidence.status));
|
|
962
|
+
if (alert) {
|
|
963
|
+
this.audit({ id, target: workId, verdict: "confirm", evidence: verdict.evidence, method: null, ok: true });
|
|
964
|
+
this.options.onUpdate?.(this.work(workId));
|
|
965
|
+
}
|
|
966
|
+
this.options.onReconciled?.();
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
private audit(line: Pick<AuditLine, "id" | "target" | "verdict" | "evidence" | "method" | "ok" | "error">): void {
|
|
970
|
+
try {
|
|
971
|
+
this.options.audit?.append({ at: new Date().toISOString(), trigger: "work_event", actor: "reconcile", collector: "reconcile-work", ...line });
|
|
972
|
+
} catch (error) { this.fail(error); }
|
|
973
|
+
}
|
|
785
974
|
}
|
package/web/DESIGN.md
CHANGED
|
@@ -49,7 +49,8 @@ former 72px rail, widened by 200px on 2026-10-04 for the 작업 상태 drawing;
|
|
|
49
49
|
below 1280px, 200px at 900px and below). The profile block is split in two halves. The left half shows the owner's nickname (double-click edits it:
|
|
50
50
|
Enter or leaving saves, Esc cancels); the gateway keeps it (`GET/PUT /api/profile`, `profile.updated`) and the
|
|
51
51
|
same name labels the user's chat messages, their avatar letter and the 작업 상태 user card. The right half shows the
|
|
52
|
-
agent's (gateway main) name with the Zig icon and the same editing (`agentName` in the same API; default "지그"
|
|
52
|
+
agent's (gateway main) name with the Zig icon and the same editing (`agentName` in the same API; the gateway stores the default "지그" or "Zig" of its language once at first start, and the
|
|
53
|
+
name is never translated afterwards (bug 271); at most
|
|
53
54
|
20 display-width units, Hangul/CJK 2 and ASCII 1; a name too wide stays red and is not saved). The main session reads
|
|
54
55
|
the name at the start of each turn, and the 작업 상태 hub shows it when WebGL is unavailable. The conversation list keeps at least 140px; the 작업 상태 drawing shrinks before it does.
|
|
55
56
|
|
|
@@ -178,3 +179,36 @@ remain visible. No animation, network, remote fonts, new settings or exports.
|
|
|
178
179
|
Source foldouts retain their local open state across streaming updates.
|
|
179
180
|
Desktop/phone light/dark, enlarged text, source/copy and reference QA belong
|
|
180
181
|
to the parent integration lane; local unit checks do not claim visual approval.
|
|
182
|
+
|
|
183
|
+
## 11. Agent collaboration cards and targets (292, C-10)
|
|
184
|
+
Brief. Primary action: none by default; 요청 취소 is the one prominent (danger) control and appears only while a request
|
|
185
|
+
can still move. Priorities: 1) who asked whom and the request text, 2) the state word, 3) the result and its files.
|
|
186
|
+
Left out on purpose: a per-request avatar row, a progress bar, a duplicate state badge in the head of the transcript,
|
|
187
|
+
and any success wording for an action the gateway refused.
|
|
188
|
+
Layout: one `collaboration-card` per request (`data-request-id`), a block in the message list aligned with message text
|
|
189
|
+
(68px left, 640px max; 16px/8px margins and full width at 640px and below); the message list stays the only scroll owner.
|
|
190
|
+
The card is drawn at the first stored event that names the request (`message.collaboration`) and is updated in place by
|
|
191
|
+
later events (`agent.collaboration.status`, the reply, blocked/delivery notes) and by the owner listing
|
|
192
|
+
(`GET /api/agent-collaboration/requests?conv=`), which is the gateway's truth for kind, state and revision; a replayed
|
|
193
|
+
event never adds a second card. web/collaboration.js decides, app.js draws.
|
|
194
|
+
Tokens: the existing `--bg-card`, `--bg-input`, `--border`, `--text`, `--muted`, `--ok`, `--warn`, `--danger`, the
|
|
195
|
+
1px #4e5058 hairline and 4px/6px radii; no side stripe. The state is a dot plus a monospace word, never colour alone:
|
|
196
|
+
ok (완료), busy (대기 중, 진행 중, 다른 에이전트의 답 대기, 결과 준비 중), warn (내 답 대기, 취소 요청됨, 전달 미확인, 정의
|
|
197
|
+
변경으로 미실행, 상태 미확인), danger (실패, 중단됨, 취소됨). An unknown or unconfirmed state never reads as done.
|
|
198
|
+
Head: title (보낸/받은 협업 요청), kind tag (작업/질문, from the listing), state, time. Parties: 보낸 쪽 → 받는 쪽; the
|
|
199
|
+
sender of a received request is its actor, the recipient of a sent request reads 다른 에이전트 until the gateway names it.
|
|
200
|
+
Body: the request text as DOM text; 요청과 함께 보낸 파일 and the 결과 files as rows (`data-artifact-id`, name, size,
|
|
201
|
+
내려받기); the result text or 회신에 본문이 없습니다; 회신 보냄 on the answering side; notes for waiting owner, blocked,
|
|
202
|
+
unconfirmed delivery and definition change. A status line (`role=status`) under the body carries the last action's
|
|
203
|
+
answer. Actions: 요청 취소 (`data-action=collaboration-cancel`, needs the listing's revision), 다시 확인
|
|
204
|
+
(`collaboration-recheck`), 내려받기 (`collaboration-download`, saved only when the gateway's sha256 header and the
|
|
205
|
+
browser's digest match the manifest). Owner actions answered 503 `owner_auth_unavailable` by the gateway are shown as
|
|
206
|
+
"not available yet", never as success. Controls keep 24px (44px on phones) targets and the #a3acff focus ring.
|
|
207
|
+
Agent editor: the gateway's `collaboration` group and `collaboration.targets` rows are drawn (the registry marks them
|
|
208
|
+
hidden for older webs) as a check list of the other agents by name (`pe-skills pe-targets`, the skills list's shape),
|
|
209
|
+
never the agent itself; a saved id the list no longer carries stays checked with a 없음 tag. Knowledge files: a source/
|
|
210
|
+
file carries its origin tag (직접 올림 or 협업 사본, the latter in the accent ink) and the copy's delete confirmation says
|
|
211
|
+
only this copy goes. Dictionaries: `collab.*`, `agents.targets.*`, `agents.file.origin.*` in web/i18n/agents.js.
|
|
212
|
+
Verification: test/unit/agent-collaboration-web.test.ts (guards doubling as mutation watchers in
|
|
213
|
+
test/mutation/agent-collaboration-web.test.ts) and the Aside browser QA test/fixtures/agent-collaboration-web-qa.ts at
|
|
214
|
+
1440x900 and 390x844 in both colour schemes (the UI ships one dark palette; light is the media emulation).
|