@awak-app/simy-cli 0.6.16 → 0.6.18
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.md +99 -6
- package/package.json +1 -1
- package/src/agent.js +123 -28
- package/src/auto-update.js +15 -3
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/connection-reminder.mjs +5 -2
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +30 -3
- package/src/codex-plugins/simy/hooks/dev-work.mjs +7 -1
- package/src/codex-plugins/simy/hooks/hooks.json +2 -2
- package/src/codex-work-pattern-sync.js +37 -9
- package/src/delivery-hooks/command.js +9 -2
- package/src/delivery-hooks/desktop.js +10 -12
- package/src/delivery-hooks/evidence.js +50 -0
- package/src/delivery-hooks/supervisor.js +8 -2
- package/src/desktop-access.js +4 -3
- package/src/desktop-host.js +669 -73
- package/src/doctor.js +2 -1
- package/src/host-exit-diagnostics.js +28 -0
- package/src/index.js +1 -0
- package/src/native-development-report-completion.js +143 -0
- package/src/native-development-reports.js +137 -0
- package/src/orchestrator/checklist-continuation.js +5 -0
- package/src/orchestrator/delivery/WORKFLOW.md +4 -0
- package/src/orchestrator/delivery/references/state-model.md +187 -0
- package/src/orchestrator/loop.js +119 -34
- package/src/provider-links.js +74 -0
- package/src/runner.js +31 -2
- package/src/session-supervision/automatic-continuation.js +31 -2
- package/src/session-supervision/claude-sessions.js +25 -15
- package/src/session-supervision/codex-app-server.js +27 -4
- package/src/session-supervision/index.js +163 -42
- package/src/session-supervision/statistics.js +76 -15
- package/src/sqm/command.js +58 -7
- package/src/sqm/evidence-client.js +11 -1
- package/src/sqm/failure-circuit.js +28 -6
- package/src/sqm/history-store.js +49 -18
- package/src/sqm/provider-diagnostics.js +1 -0
- package/src/sqm/review-worker.js +70 -21
- package/src/sqm/workflow-update.js +12 -3
- package/src/windows-bootstrap.js +2 -1
- package/src/windows-desktop-updater.js +6 -1
- package/src/windows-inbound.js +9 -0
package/src/orchestrator/loop.js
CHANGED
|
@@ -28,6 +28,10 @@ async function runCodingLoopOwned({
|
|
|
28
28
|
shouldStop = () => false,
|
|
29
29
|
consumeHumanGuidance = () => "",
|
|
30
30
|
}) {
|
|
31
|
+
if (resume && snapshot.attempts.at(-1)?.ci_review_refresh_pending) {
|
|
32
|
+
return recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm, executeIndependentAudit,
|
|
33
|
+
shouldStop, onUpdate, retryCiReview: true });
|
|
34
|
+
}
|
|
31
35
|
if (!resume && snapshot.attempts.length > 0 && !snapshot.metadata?.completed_worker_result) {
|
|
32
36
|
return recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm, onUpdate });
|
|
33
37
|
}
|
|
@@ -263,39 +267,7 @@ async function runCodingLoopOwned({
|
|
|
263
267
|
});
|
|
264
268
|
await publish(snapshot, "independent_auditing", onUpdate);
|
|
265
269
|
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
266
|
-
|
|
267
|
-
const reviewedMatrix = attempt.observed_evidence?.minimum_completion?.matrix_sha256;
|
|
268
|
-
const reviewedEvidenceIdentity = independentAuditEvidenceIdentity(attempt);
|
|
269
|
-
const reviewedObjectiveDigest = snapshot.charter.thread_state?.objective_contract?.current_digest;
|
|
270
|
-
const reviewedObjectiveVersion = snapshot.charter.thread_state?.objective_contract?.version;
|
|
271
|
-
const auditExecution = await safeExecution(() =>
|
|
272
|
-
executeIndependentAudit({
|
|
273
|
-
attemptNumber,
|
|
274
|
-
backend: snapshot.charter.audit_backend,
|
|
275
|
-
instruction: auditInstruction,
|
|
276
|
-
charter: snapshot.charter,
|
|
277
|
-
attempt,
|
|
278
|
-
}),
|
|
279
|
-
);
|
|
280
|
-
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
281
|
-
attempt.independent_audit = buildIndependentAudit(
|
|
282
|
-
auditExecution,
|
|
283
|
-
snapshot.charter,
|
|
284
|
-
attempt,
|
|
285
|
-
);
|
|
286
|
-
attempt.token_usage = mergeTokenUsage(attempt.token_usage, auditExecution.tokenUsage);
|
|
287
|
-
attempt.budget = refreshBudgetState(snapshot);
|
|
288
|
-
recordBudgetWarnings(snapshot, attempt);
|
|
289
|
-
if (attempt.budget.token_exhausted) {
|
|
290
|
-
await stopForBudget({ snapshot, attempt, reason: "token_budget_exhausted", onUpdate });
|
|
291
|
-
return snapshot;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
attempt.independent_audit.result_parse = normalizeResultParseDiagnostic(auditExecution.result_parse);
|
|
295
|
-
attempt.independent_audit.minimum_matrix_sha256 = reviewedMatrix;
|
|
296
|
-
attempt.independent_audit.evidence_identity_sha256 = reviewedEvidenceIdentity;
|
|
297
|
-
attempt.independent_audit.objective_digest = reviewedObjectiveDigest;
|
|
298
|
-
attempt.independent_audit.objective_version = reviewedObjectiveVersion;
|
|
270
|
+
if (await reviewAttempt({ snapshot, attempt, executeIndependentAudit, shouldStop, onUpdate })) return snapshot;
|
|
299
271
|
|
|
300
272
|
// Re-collect after the read-only auditor to catch any mutated HEAD or working tree.
|
|
301
273
|
attempt.observed_evidence = await safeEvidence(collectEvidence, attempt, { retries: snapshot.metadata?.completed_worker_result ? 2 : 0, shouldStop });
|
|
@@ -369,7 +341,63 @@ async function stopCodingLoop(snapshot, onUpdate) {
|
|
|
369
341
|
return snapshot;
|
|
370
342
|
}
|
|
371
343
|
|
|
372
|
-
async function
|
|
344
|
+
async function reviewAttempt({ snapshot, attempt, executeIndependentAudit, shouldStop, onUpdate }) {
|
|
345
|
+
const auditInstruction = buildIndependentAuditInstruction(snapshot.charter, attempt);
|
|
346
|
+
const reviewedMatrix = attempt.observed_evidence?.minimum_completion?.matrix_sha256;
|
|
347
|
+
const reviewedEvidenceIdentity = independentAuditEvidenceIdentity(attempt);
|
|
348
|
+
const reviewedChecks = structuredClone(attempt.observed_evidence?.github?.checks || []);
|
|
349
|
+
const reviewedObjectiveDigest = snapshot.charter.thread_state?.objective_contract?.current_digest;
|
|
350
|
+
const reviewedObjectiveVersion = snapshot.charter.thread_state?.objective_contract?.version;
|
|
351
|
+
const auditExecution = await safeExecution(() =>
|
|
352
|
+
executeIndependentAudit({
|
|
353
|
+
attemptNumber: attempt.attempt_number,
|
|
354
|
+
backend: snapshot.charter.audit_backend,
|
|
355
|
+
instruction: auditInstruction,
|
|
356
|
+
charter: snapshot.charter,
|
|
357
|
+
attempt,
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
360
|
+
if (shouldStop()) { await stopCodingLoop(snapshot, onUpdate); return true; }
|
|
361
|
+
attempt.independent_audit = buildIndependentAudit(
|
|
362
|
+
auditExecution,
|
|
363
|
+
snapshot.charter,
|
|
364
|
+
attempt,
|
|
365
|
+
);
|
|
366
|
+
attempt.token_usage = mergeTokenUsage(attempt.token_usage, auditExecution.tokenUsage);
|
|
367
|
+
attempt.budget = refreshBudgetState(snapshot);
|
|
368
|
+
recordBudgetWarnings(snapshot, attempt);
|
|
369
|
+
if (attempt.budget.token_exhausted) {
|
|
370
|
+
await stopForBudget({ snapshot, attempt, reason: "token_budget_exhausted", onUpdate });
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
attempt.independent_audit.result_parse = normalizeResultParseDiagnostic(auditExecution.result_parse);
|
|
375
|
+
attempt.independent_audit.reviewed_checks = reviewedChecks;
|
|
376
|
+
attempt.independent_audit.minimum_matrix_sha256 = reviewedMatrix;
|
|
377
|
+
attempt.independent_audit.evidence_identity_sha256 = reviewedEvidenceIdentity;
|
|
378
|
+
attempt.independent_audit.objective_digest = reviewedObjectiveDigest;
|
|
379
|
+
attempt.independent_audit.objective_version = reviewedObjectiveVersion;
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Only a CI-only change to the already reviewed candidate may automatically
|
|
384
|
+
// renew its review. Head/base/preparation/acceptance changes need explicit work.
|
|
385
|
+
function ciReviewRefreshIdentity(attempt, previousEvidence) {
|
|
386
|
+
const reviewed = attempt.independent_audit;
|
|
387
|
+
const github = attempt.observed_evidence?.github;
|
|
388
|
+
if (!reviewed?.passed || !reviewed.evidence_identity_sha256 || !github?.available ||
|
|
389
|
+
!attempt.observed_evidence?.local?.available || !attempt.observed_evidence.local.working_tree_clean ||
|
|
390
|
+
attempt.observed_evidence.local.errors?.length || github.error || !github.required_checks_present || !github.checks_passing ||
|
|
391
|
+
!github.checks?.length || github.checks.some(check => check.state !== 'success' && check.state !== 'skipped')) return null;
|
|
392
|
+
const identity = independentAuditEvidenceIdentity(attempt);
|
|
393
|
+
if (identity === reviewed.evidence_identity_sha256) return null;
|
|
394
|
+
const prior = { ...attempt, observed_evidence: { ...attempt.observed_evidence,
|
|
395
|
+
github: { ...github, checks: reviewed.reviewed_checks || previousEvidence?.github?.checks } } };
|
|
396
|
+
return independentAuditEvidenceIdentity(prior) === reviewed.evidence_identity_sha256 ? identity : null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm = null, executeIndependentAudit, shouldStop = () => false, onUpdate, retryCiReview = false }) {
|
|
400
|
+
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
373
401
|
if (!verifyObjective(snapshot.charter).valid) {
|
|
374
402
|
snapshot.implementation_gate_passed = false;
|
|
375
403
|
snapshot.done_gate_passed = false;
|
|
@@ -391,6 +419,7 @@ async function recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm = n
|
|
|
391
419
|
code: "OBJECTIVE_EVIDENCE_STALE", severity: "blocker", target: "objective_contract",
|
|
392
420
|
explanation: "Make a new attempt and independently review evidence against the current objective.", repairability: "manual",
|
|
393
421
|
}]);
|
|
422
|
+
if (retryCiReview) attempt.ci_review_refresh_pending = false;
|
|
394
423
|
await publish(snapshot, "waiting_human", onUpdate);
|
|
395
424
|
return snapshot;
|
|
396
425
|
}
|
|
@@ -398,16 +427,72 @@ async function recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm = n
|
|
|
398
427
|
attempt_number: attempt.attempt_number,
|
|
399
428
|
});
|
|
400
429
|
await publish(snapshot, "checking_pr", onUpdate);
|
|
430
|
+
const previousEvidence = attempt.observed_evidence;
|
|
431
|
+
// Recover an old review's CI basis only while the saved full identity still
|
|
432
|
+
// proves it. Later monitoring observations cannot reconstruct that history.
|
|
433
|
+
if (!attempt.independent_audit?.reviewed_checks &&
|
|
434
|
+
attempt.independent_audit?.evidence_identity_sha256 === independentAuditEvidenceIdentity(attempt) &&
|
|
435
|
+
Array.isArray(previousEvidence?.github?.checks)) {
|
|
436
|
+
attempt.independent_audit.reviewed_checks = structuredClone(previousEvidence.github.checks);
|
|
437
|
+
}
|
|
401
438
|
attempt.observed_evidence = await safeEvidence(collectEvidence, attempt);
|
|
439
|
+
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
402
440
|
attempt.sqm = await safeSqm(checkSqm, attempt, attempt.sqm, "full");
|
|
441
|
+
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
442
|
+
if (retryCiReview && attempt.observed_evidence?.local?.available && attempt.observed_evidence?.github?.available &&
|
|
443
|
+
independentAuditEvidenceIdentity(attempt) !== attempt.ci_review_refresh_identity) {
|
|
444
|
+
attempt.ci_review_refresh_pending = false;
|
|
445
|
+
snapshot.final_audit = humanGate("The candidate changed while its CI review was interrupted.", [{
|
|
446
|
+
code: "CI_REVIEW_CANDIDATE_CHANGED", severity: "blocker", target: "independent_audit",
|
|
447
|
+
explanation: "Reconcile the changed HEAD, base or evidence, then explicitly continue work; the prior review cannot certify this candidate.", repairability: "manual",
|
|
448
|
+
}]);
|
|
449
|
+
await publish(snapshot, "waiting_human", onUpdate);
|
|
450
|
+
return snapshot;
|
|
451
|
+
}
|
|
452
|
+
const retryIdentity = retryCiReview && attempt.ci_review_refresh_pending &&
|
|
453
|
+
independentAuditEvidenceIdentity(attempt) === attempt.ci_review_refresh_identity &&
|
|
454
|
+
attempt.observed_evidence?.local?.available && attempt.observed_evidence.local.working_tree_clean &&
|
|
455
|
+
!attempt.observed_evidence.local.errors?.length && attempt.observed_evidence?.github?.available &&
|
|
456
|
+
!attempt.observed_evidence.github.error && attempt.observed_evidence.github.checks_passing &&
|
|
457
|
+
attempt.observed_evidence.github.required_checks_present ? attempt.ci_review_refresh_identity : null;
|
|
458
|
+
const refreshIdentity = retryIdentity || ciReviewRefreshIdentity(attempt, previousEvidence);
|
|
459
|
+
if (executeIndependentAudit && refreshIdentity && (retryIdentity || attempt.ci_review_refresh_identity !== refreshIdentity)) {
|
|
460
|
+
const budget = canStartProvider(snapshot, { attemptNumber: attempt.attempt_number, phase: "reviewer" });
|
|
461
|
+
if (!budget.allowed) { await stopForBudget({ snapshot, attempt, reason: budget.reason, onUpdate }); return snapshot; }
|
|
462
|
+
// Persist before dispatch so an interrupted/failed review cannot repeat on
|
|
463
|
+
// every monitor tick. Explicit resume retries only this bound review.
|
|
464
|
+
attempt.ci_review_refresh_identity = refreshIdentity;
|
|
465
|
+
attempt.ci_review_refresh_pending = true;
|
|
466
|
+
appendEvent(snapshot, "independent_auditing", "CI completed; refreshing independent review of the same candidate.", {
|
|
467
|
+
attempt_number: attempt.attempt_number, evidence_identity: refreshIdentity,
|
|
468
|
+
});
|
|
469
|
+
await publish(snapshot, "independent_auditing", onUpdate);
|
|
470
|
+
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
471
|
+
if (await reviewAttempt({ snapshot, attempt, executeIndependentAudit, shouldStop, onUpdate })) return snapshot;
|
|
472
|
+
if (!attempt.independent_audit.passed &&
|
|
473
|
+
!attempt.independent_audit.findings.some(finding => finding.code === "INDEPENDENT_AUDIT_UNAVAILABLE")) {
|
|
474
|
+
// A completed adverse review belongs to the normal repair path. Only
|
|
475
|
+
// unavailable/interrupted reviews need a reviewer-only retry.
|
|
476
|
+
attempt.ci_review_refresh_pending = false;
|
|
477
|
+
}
|
|
478
|
+
attempt.observed_evidence = await safeEvidence(collectEvidence, attempt);
|
|
479
|
+
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
480
|
+
attempt.sqm = await safeSqm(checkSqm, attempt, attempt.sqm, "full");
|
|
481
|
+
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
482
|
+
}
|
|
403
483
|
attempt.audit = await auditAttempt(snapshot.charter, attempt, {
|
|
404
484
|
previousAttempt: snapshot.attempts.at(-2) ?? null,
|
|
405
485
|
sqm: attempt.sqm,
|
|
406
486
|
});
|
|
487
|
+
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
407
488
|
attempt.outcome_progress = assessOutcomeProgress(
|
|
408
489
|
snapshot.charter, attempt, snapshot.attempts.at(-2) ?? null,
|
|
409
490
|
outcomeOptions(snapshot),
|
|
410
491
|
);
|
|
492
|
+
if (attempt.ci_review_refresh_pending && attempt.audit.passed && attempt.independent_audit?.passed &&
|
|
493
|
+
attempt.independent_audit.evidence_identity_sha256 === independentAuditEvidenceIdentity(attempt)) {
|
|
494
|
+
attempt.ci_review_refresh_pending = false;
|
|
495
|
+
}
|
|
411
496
|
snapshot.implementation_gate_passed = attempt.audit.passed;
|
|
412
497
|
attempt.pr_readiness = evaluatePrReadiness(snapshot.charter, attempt);
|
|
413
498
|
attempt.pr_gate = attempt.pr_readiness;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const PROVIDER_LINKS = Object.freeze(['codex', 'claude']);
|
|
7
|
+
const defaultRoot = () => process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
|
|
8
|
+
const writeQueues = new Map();
|
|
9
|
+
const defaults = () => Object.fromEntries(PROVIDER_LINKS.map(provider => [provider, { connected: true, epoch: null, requiresConsent: false }]));
|
|
10
|
+
const disconnected = () => Object.fromEntries(PROVIDER_LINKS.map(provider => [provider, { connected: false, epoch: null, requiresConsent: true, settingsError: true }]));
|
|
11
|
+
const validateProvider = provider => { if (!PROVIDER_LINKS.includes(provider)) throw new Error('Unsupported SIMY provider connection.'); };
|
|
12
|
+
|
|
13
|
+
async function readSettings(root) {
|
|
14
|
+
let payload;
|
|
15
|
+
try { payload = JSON.parse(await readFile(join(root, 'provider-links.json'), 'utf8')); }
|
|
16
|
+
catch (error) { if (error.code === 'ENOENT') return defaults(); throw error; }
|
|
17
|
+
if (payload?.schemaVersion !== 1 || !payload.providers || PROVIDER_LINKS.some(provider => {
|
|
18
|
+
const value = payload.providers[provider];
|
|
19
|
+
return !value || typeof value.connected !== 'boolean' || typeof value.requiresConsent !== 'boolean' ||
|
|
20
|
+
value.epoch !== null && (typeof value.epoch !== 'string' || !value.epoch || value.epoch.length > 128);
|
|
21
|
+
})) throw new Error('Invalid SIMY provider connection settings.');
|
|
22
|
+
return Object.fromEntries(PROVIDER_LINKS.map(provider => [provider, {
|
|
23
|
+
connected: payload.providers[provider].connected,
|
|
24
|
+
epoch: payload.providers[provider].epoch,
|
|
25
|
+
requiresConsent: payload.providers[provider].requiresConsent,
|
|
26
|
+
}]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Existing installations stay linked until the user explicitly disconnects.
|
|
30
|
+
// An unreadable saved preference fails closed instead of restoring revoked consent.
|
|
31
|
+
export async function readProviderLinks(root = defaultRoot()) {
|
|
32
|
+
try { return await readSettings(root); } catch { return disconnected(); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function providerLinkConnected(root = defaultRoot(), provider) {
|
|
36
|
+
validateProvider(provider);
|
|
37
|
+
return (await readProviderLinks(root))[provider].connected;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Session APIs use a delivery-hooks root, while desktop/agent policy uses SIMY_HOME.
|
|
41
|
+
export async function requireProviderLink({ root = join(defaultRoot(), 'delivery-hooks'), provider, expectedEpoch } = {}) {
|
|
42
|
+
validateProvider(provider);
|
|
43
|
+
const link = (await readProviderLinks(dirname(resolve(root))))[provider];
|
|
44
|
+
if (!link.connected) {
|
|
45
|
+
throw Object.assign(new Error('Reconnect this provider to SIMY before using it.'), { code: 'provider_link_disconnected' });
|
|
46
|
+
}
|
|
47
|
+
if (expectedEpoch !== undefined && expectedEpoch !== link.epoch) {
|
|
48
|
+
throw Object.assign(new Error('SIMY provider connection changed. Select the conversation again.'), { code: 'provider_link_changed' });
|
|
49
|
+
}
|
|
50
|
+
return link;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function writeProviderLink(root = defaultRoot(), provider, connected) {
|
|
54
|
+
validateProvider(provider);
|
|
55
|
+
if (typeof connected !== 'boolean') throw new Error('Choose whether this provider is connected to SIMY.');
|
|
56
|
+
const key = resolve(root);
|
|
57
|
+
const previous = writeQueues.get(key) || Promise.resolve();
|
|
58
|
+
const pending = previous.catch(() => {}).then(async () => {
|
|
59
|
+
const providers = await readSettings(root);
|
|
60
|
+
// Reconnection restores discovery only. Session execution consent is cleared
|
|
61
|
+
// separately and must be selected again, including after a host restart.
|
|
62
|
+
providers[provider] = { connected, epoch: randomUUID(), requiresConsent: true };
|
|
63
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
64
|
+
const target = join(root, 'provider-links.json'), temporary = `${target}.${randomUUID()}.tmp`;
|
|
65
|
+
try {
|
|
66
|
+
await writeFile(temporary, `${JSON.stringify({ schemaVersion: 1, providers })}\n`, { mode: 0o600 });
|
|
67
|
+
await rename(temporary, target);
|
|
68
|
+
} finally { await rm(temporary, { force: true }); }
|
|
69
|
+
return providers;
|
|
70
|
+
});
|
|
71
|
+
writeQueues.set(key, pending);
|
|
72
|
+
void pending.finally(() => { if (writeQueues.get(key) === pending) writeQueues.delete(key); }).catch(() => {});
|
|
73
|
+
return pending;
|
|
74
|
+
}
|
package/src/runner.js
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
normalizeWorkflowStartIntent,
|
|
52
52
|
} from "./workflow-observation-sender.js";
|
|
53
53
|
import { saveCodingRunSnapshot } from "./coding-run-store.js";
|
|
54
|
-
import { acquireDesktopProcessLock } from "./desktop-process-lock.js";
|
|
54
|
+
import { acquireDesktopProcessLock, PROCESS_CONFLICT_CODE } from "./desktop-process-lock.js";
|
|
55
55
|
import {
|
|
56
56
|
assertRecoveryActor, completedWorkerResult, recoveryActorBinding, refreshRecoveryAudit, refreshRecoveryBudgetAudit,
|
|
57
57
|
} from "./coding-run-recovery.js";
|
|
@@ -924,7 +924,24 @@ async function cleanupLocalAttachments(run) {
|
|
|
924
924
|
}
|
|
925
925
|
}
|
|
926
926
|
|
|
927
|
-
export async function recheckLocalCodingRun(run,
|
|
927
|
+
export async function recheckLocalCodingRun(run, options = {}) {
|
|
928
|
+
if (!run?.snapshot?.attempts?.length) return run?.snapshot || null;
|
|
929
|
+
if (!["pr_ready_for_review", "merge_ready"].includes(run.status)) return run.snapshot;
|
|
930
|
+
if (run.operation || run.child || run.stopRequested) return run.snapshot;
|
|
931
|
+
const operation = (async () => {
|
|
932
|
+
assertRecoveryActor(run);
|
|
933
|
+
const lease = run.localStateRoot ? await acquireDesktopProcessLock(
|
|
934
|
+
path.join(run.localStateRoot, ".controllers", executionTextSha256(run.id)),
|
|
935
|
+
) : null;
|
|
936
|
+
try { return await recheckLocalCodingRunOwned(run, options); }
|
|
937
|
+
finally { await lease?.release(); }
|
|
938
|
+
})();
|
|
939
|
+
run.operation = operation;
|
|
940
|
+
try { return await operation; }
|
|
941
|
+
finally { if (run.operation === operation) run.operation = null; }
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
async function recheckLocalCodingRunOwned(run, { collectEvidence, executeIndependentAudit, authorizeReview } = {}) {
|
|
928
945
|
if (!run?.snapshot?.attempts?.length) return run?.snapshot || null;
|
|
929
946
|
if (!["pr_ready_for_review", "merge_ready"].includes(run.status)) return run.snapshot;
|
|
930
947
|
const repositoryPath = await resolveRepositoryPath(run.request);
|
|
@@ -956,6 +973,15 @@ export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
|
|
|
956
973
|
await recheckPrReadiness({
|
|
957
974
|
snapshot: run.snapshot,
|
|
958
975
|
collectEvidence: evidenceCollector,
|
|
976
|
+
executeIndependentAudit: async context => {
|
|
977
|
+
await authorizeReview?.();
|
|
978
|
+
assertRecoveryActor(run);
|
|
979
|
+
if (run.stopRequested) throw new Error("Review cancelled by the local operator.");
|
|
980
|
+
return executeIndependentAudit ? executeIndependentAudit(context) : executeProcessAttempt({
|
|
981
|
+
...context, repositoryPath, run, marker: "SIMY_AUDIT_JSON:",
|
|
982
|
+
});
|
|
983
|
+
},
|
|
984
|
+
shouldStop: () => run.stopRequested,
|
|
959
985
|
checkSqm: sqmChecker,
|
|
960
986
|
onUpdate: async (snapshot) => {
|
|
961
987
|
run.snapshot = snapshot;
|
|
@@ -966,6 +992,8 @@ export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
|
|
|
966
992
|
}
|
|
967
993
|
|
|
968
994
|
export async function markCodingRunMonitorFailure(run, error) {
|
|
995
|
+
// A losing controller never owns the right to persist its stale snapshot.
|
|
996
|
+
if (run.stopRequested || run.status === "stopped" || error?.code === PROCESS_CONFLICT_CODE) return run.snapshot;
|
|
969
997
|
const message = error instanceof Error ? error.message : String(error);
|
|
970
998
|
run.snapshot.done_gate_passed = false;
|
|
971
999
|
run.snapshot.final_audit = {
|
|
@@ -1026,6 +1054,7 @@ export async function executeProcessAttempt({
|
|
|
1026
1054
|
model: phase === "reviewer" ? charter?.audit_model : charter?.model,
|
|
1027
1055
|
reasoningEffort: phase === "reviewer" ? charter?.audit_reasoning_effort : charter?.reasoning_effort,
|
|
1028
1056
|
});
|
|
1057
|
+
if (run.stopRequested) throw new Error("Execution cancelled by the local operator.");
|
|
1029
1058
|
const startedAt = new Date().toISOString();
|
|
1030
1059
|
const stdout = [];
|
|
1031
1060
|
const stderr = [];
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import { continueSession, readSessionAutoContinue } from './index.js';
|
|
2
2
|
import { readContinuationContext } from './continuation-analysis.js';
|
|
3
|
+
import { requireProviderLink } from '../provider-links.js';
|
|
4
|
+
|
|
5
|
+
function continuationFailureReason(error) {
|
|
6
|
+
// Provider exceptions can contain private paths/output. Only known codes
|
|
7
|
+
// select actionable UI copy; unrecognized failures retain a safe fallback.
|
|
8
|
+
const reasons = {
|
|
9
|
+
desktop_access_required: 'Sign in to an organization with SQM and Developer access before auto-continue can run.',
|
|
10
|
+
provider_sign_in_required: 'Sign in to the original AI provider, then retry auto-continue.',
|
|
11
|
+
unavailable: 'The local provider is unavailable. Open or configure it, then retry auto-continue.',
|
|
12
|
+
unsupported: 'Update the original AI provider to a compatible version, then retry auto-continue.',
|
|
13
|
+
process_exited: 'The original Claude process has exited. Return to the original conversation, then refresh.',
|
|
14
|
+
session_ended: 'This Claude session has ended. Return to the original conversation, then refresh.',
|
|
15
|
+
hook_missing: 'SIMY has not observed the original Claude session-start hook. Check the plugin and hook execution, then refresh.',
|
|
16
|
+
};
|
|
17
|
+
return Object.hasOwn(reasons, error?.code) ? reasons[error.code] : 'Could not start auto-continue. Check the original conversation and retry.';
|
|
18
|
+
}
|
|
3
19
|
|
|
4
20
|
// Shared by explicit Continue and automatic enrollment. Analysis alone never
|
|
5
21
|
// dispatches work: the exact current provider context is checked again here.
|
|
6
22
|
export async function continueAnalyzedSession({ candidate, manager, root, settings = {}, signal,
|
|
7
23
|
readContext = readContinuationContext, enroll = continueSession, beforeEnroll = () => {} }) {
|
|
24
|
+
const link = await requireProviderLink({ root, provider: candidate.provider, expectedEpoch: settings.expectedProviderEpoch });
|
|
8
25
|
const recommendation = manager.requireRecommendation(candidate);
|
|
9
26
|
if (candidate.isRunning || candidate.capabilities?.canContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
|
|
10
27
|
const context = await readContext(candidate, { signal });
|
|
@@ -14,8 +31,10 @@ export async function continueAnalyzedSession({ candidate, manager, root, settin
|
|
|
14
31
|
}
|
|
15
32
|
manager.requireRecommendation(candidate);
|
|
16
33
|
await beforeEnroll(candidate);
|
|
34
|
+
await requireProviderLink({ root, provider: candidate.provider, expectedEpoch: link.epoch });
|
|
35
|
+
manager.requireRecommendation(candidate);
|
|
17
36
|
if (signal?.aborted) throw new Error('Automatic continuation was cancelled before enrollment.');
|
|
18
|
-
return enroll({ root, provider: candidate.provider, session: candidate.session, ...settings });
|
|
37
|
+
return enroll({ root, provider: candidate.provider, session: candidate.session, ...settings, expectedProviderEpoch: link.epoch });
|
|
19
38
|
}
|
|
20
39
|
|
|
21
40
|
// One pending enrollment per identity. A failed attempt is retried only after
|
|
@@ -54,6 +73,7 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
|
|
|
54
73
|
errors.delete(key);
|
|
55
74
|
const controller = new AbortController();
|
|
56
75
|
const guard = async () => {
|
|
76
|
+
await requireProviderLink({ root, provider: candidate.provider });
|
|
57
77
|
const setting = await readSetting({ root, provider: candidate.provider, session: candidate.session });
|
|
58
78
|
if (closed || controller.signal.aborted || !setting.enabled || setting.epoch !== candidate.autoContinueEpoch || setting.hasRun) throw new Error('Automatic continuation was disabled or already enrolled.');
|
|
59
79
|
return setting;
|
|
@@ -67,7 +87,7 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
|
|
|
67
87
|
settings: { ...setting.settings, autoContinueEpoch: candidate.autoContinueEpoch } });
|
|
68
88
|
}).catch(error => {
|
|
69
89
|
if (!closed && !controller.signal.aborted && !/disabled|already enrolled|setting changed|conversation changed; wait|needs a current recommendation/i.test(error?.message || '')) {
|
|
70
|
-
errors.set(key, { epoch: candidate.autoContinueEpoch, reason:
|
|
90
|
+
errors.set(key, { epoch: candidate.autoContinueEpoch, reason: continuationFailureReason(error) });
|
|
71
91
|
}
|
|
72
92
|
}).finally(() => { pending.delete(key); if (!closed) onChange(); });
|
|
73
93
|
pending.set(key, { job, controller });
|
|
@@ -79,11 +99,20 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
|
|
|
79
99
|
if (!closed && !pending.has(key)) { attempted.delete(key); errors.delete(key); }
|
|
80
100
|
},
|
|
81
101
|
project(candidate) {
|
|
102
|
+
if (candidate.autoContinue === true && candidate.isRunning !== true &&
|
|
103
|
+
['unmanaged', 'monitor'].includes(candidate.binding?.mode || 'unmanaged') && candidate.analysis?.state === 'needs_user') return {
|
|
104
|
+
...candidate, autoContinueStatus: 'needs_user', autoContinueReason: candidate.analysis.reason || 'Your decision is needed before automatic continuation can proceed.',
|
|
105
|
+
};
|
|
82
106
|
const error = errors.get(`${candidate.provider}:${candidate.session}`);
|
|
83
107
|
if (candidate.autoContinue === true && candidate.autoContinueEpoch === error?.epoch && candidate.capabilities?.canContinue === true &&
|
|
84
108
|
!['no_action', 'needs_user', 'waiting', 'uncertain'].includes(candidate.analysis?.state)) return {
|
|
85
109
|
...candidate, autoContinueStatus: 'error', autoContinueReason: error.reason,
|
|
86
110
|
};
|
|
111
|
+
if (candidate.autoContinue === true && !candidate.isRunning && candidate.capabilities?.canContinue === true) {
|
|
112
|
+
const statuses = { no_action: 'finished', needs_user: 'needs_user', waiting: 'waiting', uncertain: 'error', failed: 'error' };
|
|
113
|
+
const status = statuses[candidate.analysis?.state];
|
|
114
|
+
if (status && candidate.analysis?.reason) return { ...candidate, autoContinueStatus: status, autoContinueReason: candidate.analysis.reason };
|
|
115
|
+
}
|
|
87
116
|
return candidate;
|
|
88
117
|
},
|
|
89
118
|
revoke(provider, session) { pending.get(`${provider}:${session}`)?.controller.abort(); },
|
|
@@ -12,7 +12,6 @@ const MAX_PROJECTS = 256;
|
|
|
12
12
|
const MAX_TRANSCRIPTS = 4096;
|
|
13
13
|
const MAX_PREFIX_BYTES = 256 * 1024;
|
|
14
14
|
const MAX_LINES = 256;
|
|
15
|
-
const MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
16
15
|
// Metadata is observation only. Exact-session/context readers always open the
|
|
17
16
|
// transcript again; cached values contain no conversation message bodies.
|
|
18
17
|
const metadataCache = new Map();
|
|
@@ -99,7 +98,8 @@ async function latestCustomTitle(handle, size, id) {
|
|
|
99
98
|
return title;
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false, includeStatistics = false) {
|
|
101
|
+
async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false, includeStatistics = false, signal) {
|
|
102
|
+
signal?.throwIfAborted();
|
|
103
103
|
const id = fileName.slice(0, -'.jsonl'.length);
|
|
104
104
|
if (!UUID.test(id)) return null;
|
|
105
105
|
const project = join(root, projectName);
|
|
@@ -108,7 +108,7 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
|
|
|
108
108
|
let handle;
|
|
109
109
|
try {
|
|
110
110
|
const before = await lstat(path);
|
|
111
|
-
if (!before.isFile() || before.isSymbolicLink()
|
|
111
|
+
if (!before.isFile() || before.isSymbolicLink()) { metadataCache.delete(path); return null; }
|
|
112
112
|
if (!within(root, await realpath(path))) { metadataCache.delete(path); return null; }
|
|
113
113
|
let key;
|
|
114
114
|
if (cacheMetadata) {
|
|
@@ -141,10 +141,11 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
|
|
|
141
141
|
}
|
|
142
142
|
if (!metadata) return null;
|
|
143
143
|
const candidate = candidateMetadata(id, metadata, opened.mtimeMs, observedAt);
|
|
144
|
-
|
|
144
|
+
signal?.throwIfAborted();
|
|
145
|
+
if (includeStatistics) candidate.statistics = await statisticsFromHandle(handle, opened, 'claude', id, { signal });
|
|
145
146
|
if (includeContext) candidate.context = await conversationFromHandle(handle, opened, id, metadata.cwd);
|
|
146
147
|
return candidate;
|
|
147
|
-
} catch { metadataCache.delete(path); return null; }
|
|
148
|
+
} catch (error) { metadataCache.delete(path); if (signal?.aborted) throw error; return null; }
|
|
148
149
|
finally { await handle?.close(); }
|
|
149
150
|
}
|
|
150
151
|
|
|
@@ -174,7 +175,7 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
|
|
|
174
175
|
const file = candidates[next++];
|
|
175
176
|
try {
|
|
176
177
|
const info = await lstat(join(project, file.name));
|
|
177
|
-
if (info.isFile() && !info.isSymbolicLink()
|
|
178
|
+
if (info.isFile() && !info.isSymbolicLink())
|
|
178
179
|
descriptors.push({ projectName: entry.name, fileName: file.name,
|
|
179
180
|
session: file.name.slice(0, -'.jsonl'.length), lastActivityAt: info.mtimeMs });
|
|
180
181
|
} catch { /* A disappearing transcript does not stop discovery. */ }
|
|
@@ -185,13 +186,20 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
|
|
|
185
186
|
const selected = session ? descriptors : recentSessionWindow(descriptors, {
|
|
186
187
|
limit: maximum, priority: item => priorities.has(item.session),
|
|
187
188
|
});
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
189
|
+
const filesToInspect = selected.slice(0, MAX_TRANSCRIPTS);
|
|
190
|
+
let nextFile = 0;
|
|
191
|
+
// Catalog prefixes are bounded independently of full transcript size. Use a
|
|
192
|
+
// small I/O pool; exact-context/statistics reads stay serial and identity-safe.
|
|
193
|
+
await Promise.all(Array.from({ length: Math.min(includeContext || includeStatistics ? 1 : 8, filesToInspect.length) }, async () => {
|
|
194
|
+
while (nextFile < filesToInspect.length && !signal?.aborted) {
|
|
195
|
+
const file = filesToInspect[nextFile++];
|
|
196
|
+
const candidate = await inspectTranscript(root, file.projectName, file.fileName, observedAt, includeContext, cacheMetadata, includeStatistics, signal);
|
|
197
|
+
if (!candidate) continue;
|
|
198
|
+
const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics ? `\0${file.projectName}\0${file.fileName}` : ''}`;
|
|
199
|
+
if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
|
|
200
|
+
}
|
|
201
|
+
}));
|
|
202
|
+
if (signal?.aborted) return [];
|
|
195
203
|
const sorted = [...result.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.session.localeCompare(b.session));
|
|
196
204
|
return session ? sorted : recentSessionWindow(sorted, { limit: maximum, priority: item => priorities.has(item.session) });
|
|
197
205
|
}
|
|
@@ -249,8 +257,10 @@ export async function readClaudeConversation(candidate, { home, signal } = {}) {
|
|
|
249
257
|
return { ...selected[0].context, awaitingTurn: candidate.isRunning === true };
|
|
250
258
|
}
|
|
251
259
|
|
|
252
|
-
export async function readClaudeStatistics(candidate, { home } = {}) {
|
|
260
|
+
export async function readClaudeStatistics(candidate, { home, signal } = {}) {
|
|
261
|
+
signal?.throwIfAborted();
|
|
253
262
|
if (!UUID.test(candidate?.session || '')) return unavailableStatistics();
|
|
254
|
-
const matches = (await collect({ home, session: candidate.session, includeStatistics: true })).filter(item => item.cwd === candidate.cwd);
|
|
263
|
+
const matches = (await collect({ home, session: candidate.session, includeStatistics: true, signal })).filter(item => item.cwd === candidate.cwd);
|
|
264
|
+
signal?.throwIfAborted();
|
|
255
265
|
return matches.length === 1 ? matches[0].statistics : unavailableStatistics();
|
|
256
266
|
}
|
|
@@ -15,7 +15,9 @@ const ANALYSIS_MAX_LINE = 1024 * 1024;
|
|
|
15
15
|
const ANALYSIS_MAX_TURNS = 16;
|
|
16
16
|
const ANALYSIS_PAGE_SIZE = 6;
|
|
17
17
|
const ANALYSIS_MAX_WIRE_LINE = 8 * 1024 * 1024;
|
|
18
|
-
|
|
18
|
+
// Non-interactive exec and unknown origins are not evidence of a human-created
|
|
19
|
+
// conversation. Explicit bindings remain visible through discoverSessions.
|
|
20
|
+
const SOURCE_KINDS = ['cli', 'vscode', 'appServer'];
|
|
19
21
|
|
|
20
22
|
export class CodexAppServerError extends Error {
|
|
21
23
|
constructor(code, message) {
|
|
@@ -190,7 +192,7 @@ export function codexThreadMetadata(thread) {
|
|
|
190
192
|
};
|
|
191
193
|
}
|
|
192
194
|
|
|
193
|
-
export async function listCodexSessions({ client, maxPages = 40, pageSize = 25, maxSessions = RECENT_SESSION_LIMIT, archived = false, inspectState = false, prioritySessions = [], observationBudgetMs = 3000 } = {}) {
|
|
195
|
+
export async function listCodexSessions({ client, maxPages = 40, pageSize = 25, maxSessions = RECENT_SESSION_LIMIT, archived = false, inspectState = false, prioritySessions = [], observationBudgetMs = 3000, codexHome } = {}) {
|
|
194
196
|
const sessions = [];
|
|
195
197
|
const windowSize = Math.min(maxSessions, maxPages * pageSize);
|
|
196
198
|
let cursor = null, listed = 0, pages = 0, batchSize = pageSize;
|
|
@@ -210,18 +212,39 @@ export async function listCodexSessions({ client, maxPages = 40, pageSize = 25,
|
|
|
210
212
|
listed += threads.length; pages++;
|
|
211
213
|
for (const thread of threads) {
|
|
212
214
|
const metadata = codexThreadMetadata(thread);
|
|
213
|
-
if (metadata) sessions.push(metadata);
|
|
215
|
+
if (metadata && SOURCE_KINDS.includes(metadata.source)) sessions.push(metadata);
|
|
214
216
|
}
|
|
215
217
|
cursor = response?.nextCursor || null;
|
|
216
218
|
if (!cursor) break;
|
|
217
219
|
}
|
|
220
|
+
const deadline = Date.now() + Math.max(1, Math.min(observationBudgetMs, 5000));
|
|
221
|
+
// A recent page can omit an explicitly supervised conversation, including
|
|
222
|
+
// a newly created desktop chat. Read its exact metadata without loading or
|
|
223
|
+
// resuming it; only an active rollout path proves that it is not archived.
|
|
224
|
+
if (!archived && prioritySessions.length) {
|
|
225
|
+
const recoveryDeadline = Math.min(deadline, Date.now() + Math.max(1, (deadline - Date.now()) / 3));
|
|
226
|
+
const listedIds = new Set(sessions.map(item => item.session));
|
|
227
|
+
const missing = [...new Set(prioritySessions)].filter(id => !listedIds.has(id));
|
|
228
|
+
let next = 0;
|
|
229
|
+
await Promise.all(Array.from({ length: Math.min(3, missing.length) }, async () => {
|
|
230
|
+
while (next < missing.length && Date.now() < recoveryDeadline) {
|
|
231
|
+
const id = missing[next++];
|
|
232
|
+
try {
|
|
233
|
+
const response = await client.request('thread/read', { threadId: id, includeTurns: false },
|
|
234
|
+
Math.max(1, Math.min(1000, recoveryDeadline - Date.now())));
|
|
235
|
+
const metadata = codexThreadMetadata(response?.thread);
|
|
236
|
+
if (metadata?.session === id && SOURCE_KINDS.includes(metadata.source) &&
|
|
237
|
+
codexArchivePathState(response.thread.path, id, codexHome) === false) sessions.push(metadata);
|
|
238
|
+
} catch { /* Missing or unverifiable metadata remains a retained hook record. */ }
|
|
239
|
+
}
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
218
242
|
if (inspectState) {
|
|
219
243
|
// Status-only pages have no conversation text. A separate app-server often
|
|
220
244
|
// reports notLoaded rather than the original writer's actual turn state.
|
|
221
245
|
// Bound this metadata fan-out independently of the analysis worker pool.
|
|
222
246
|
const priorities = new Set(prioritySessions);
|
|
223
247
|
const ordered = [...sessions].sort((a, b) => Number(priorities.has(b.session)) - Number(priorities.has(a.session)) || (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
|
|
224
|
-
const deadline = Date.now() + Math.max(1, Math.min(observationBudgetMs, 5000));
|
|
225
248
|
let next = 0, unavailable = false;
|
|
226
249
|
for (const metadata of sessions) metadata.goalReadState = 'unavailable';
|
|
227
250
|
await Promise.all(Array.from({ length: Math.min(3, sessions.length) }, async () => {
|