@@ -380,9 +380,14 @@ export function createDeployTools(options?: {
380380 const materials : ArtifactRef [ ] = [ ] ;
381381 const approved = new Map < string , number > ( ) ;
382382 const openQuestions = new Map < string , number > ( ) ;
383+ // Options a person has yet to choose from mean the design is already under way.
384+ let designOptions = false ;
383385 for ( const item of list . artifacts ) {
384386 const { kind, artifactId, version, sha256, approvedVersion } = item ;
385- if ( kind === "design_option" ) continue ;
387+ if ( kind === "design_option" ) {
388+ designOptions = true ;
389+ continue ;
390+ }
386391 if ( kind === MATERIAL_KIND )
387392 materials . push ( { kind, artifactId, version, sha256 } ) ;
388393 byKind . set ( kind , { artifactId, version, sha256 } ) ;
@@ -396,6 +401,7 @@ export function createDeployTools(options?: {
396401 materials,
397402 approved,
398403 openQuestions,
404+ designOptions,
399405 inFlight : list . inFlight ,
400406 preferences : list . preferences ,
401407 } ;
@@ -430,18 +436,96 @@ export function createDeployTools(options?: {
430436 ) => hub ( projectPath + "/artifact-approvals" , { tool, artifacts } ) ;
431437 const refsOf = ( byKind : Map < string , Ref > ) =>
432438 [ ...byKind ] . map ( ( [ kind , ref ] ) => ( { kind, ...ref } ) ) ;
439+ const notFiled = ( kind : string ) =>
440+ new Refusal (
441+ kind +
442+ "_not_filed: " +
443+ ( kind === "design"
444+ ? "have the designer make it with design_app first"
445+ : "write and file the " + kind + " first" ) ,
446+ ) ;
433447 const required = ( byKind : Map < string , Ref > , kind : string ) => {
434448 const ref = byKind . get ( kind ) ;
435- if ( ! ref )
436- throw new Refusal (
437- kind +
438- "_not_filed: " +
439- ( kind === "design"
440- ? "have the designer make it with design_app first"
441- : "write and file the " + kind + " first" ) ,
442- ) ;
449+ if ( ! ref ) throw notFiled ( kind ) ;
443450 return ref ;
444451 } ;
452+ type Listed = Awaited < ReturnType < typeof listed > > ;
453+ /*
454+ * Each start tool refuses with its gate's answer, and an approval reports the steps whose
455+ * gates then pass. The coordinator learns what it may start from the rule that enforces it,
456+ * so loosening a gate loosens what it is told.
457+ */
458+ const writerGate = (
459+ { byKind, approved, preferences } : Listed ,
460+ kind : ( typeof DOCUMENT_ORDER ) [ number ] ,
461+ ) : Refusal | undefined => {
462+ if ( ! byKind . has ( "problem_brief" ) ) return notFiled ( "problem_brief" ) ;
463+ const earlier = DOCUMENT_ORDER . slice ( 0 , DOCUMENT_ORDER . indexOf ( kind ) ) ;
464+ // An imported project may lack an earlier document that its later ones were written without.
465+ const writtenWithout = ( index : number ) =>
466+ DOCUMENT_ORDER . slice ( index + 1 ) . some ( ( later ) => byKind . has ( later ) ) ;
467+ for ( const [ index , before ] of earlier . entries ( ) ) {
468+ const ref = byKind . get ( before ) ;
469+ if ( ! ref ) {
470+ if ( writtenWithout ( index ) ) continue ;
471+ return new Refusal (
472+ before +
473+ "_missing: " +
474+ ( before === "design"
475+ ? "have the designer make it with design_app"
476+ : "write and file the " + before ) +
477+ " before the " +
478+ kind ,
479+ ) ;
480+ }
481+ if (
482+ ! agentDecidesApprovals ( preferences ) &&
483+ approved . get ( before ) !== ref . version
484+ )
485+ return notApproved ( before , ref . version ) ;
486+ }
487+ return undefined ;
488+ } ;
489+ const designGate = ( { byKind } : Listed ) => {
490+ const missing = DESIGNER_INPUTS . find ( ( kind ) => ! byKind . has ( kind ) ) ;
491+ return missing === undefined ? undefined : notFiled ( missing ) ;
492+ } ;
493+ const coderGate = ( { byKind, approved, preferences } : Listed ) => {
494+ const missing = CODER_DOCUMENTS . find ( ( kind ) => ! byKind . has ( kind ) ) ;
495+ if ( missing !== undefined ) return notFiled ( missing ) ;
496+ for ( const kind of DOCUMENT_ORDER ) {
497+ const ref = byKind . get ( kind ) ;
498+ if ( ! ref ) return notFiled ( kind ) ;
499+ // The design is approved by choosing it, whoever decides the other documents.
500+ if (
501+ ( kind === "design" || ! agentDecidesApprovals ( preferences ) ) &&
502+ approved . get ( kind ) !== ref . version
503+ )
504+ return notApproved ( kind , ref . version ) ;
505+ }
506+ return undefined ;
507+ } ;
508+ // A step already made or under way is not offered again, whatever its gate says.
509+ const startable = ( state : Listed ) => {
510+ const busy = new Set ( state . inFlight . map ( ( run ) => run . role ) ) ;
511+ const open = (
512+ step : string ,
513+ role : string ,
514+ gate : Refusal | undefined ,
515+ ) => ! state . byKind . has ( step ) && ! busy . has ( role ) && gate === undefined ;
516+ return [
517+ ...DOCUMENT_ORDER . filter (
518+ ( kind ) =>
519+ kind in WRITER_PACKAGES &&
520+ open ( kind , "writer" , writerGate ( state , kind ) ) ,
521+ ) ,
522+ ...( ! state . designOptions &&
523+ open ( "design" , "designer" , designGate ( state ) )
524+ ? [ "design" ]
525+ : [ ] ) ,
526+ ...( open ( "app" , "coder" , coderGate ( state ) ) ? [ "app" ] : [ ] ) ,
527+ ] ;
528+ } ;
445529 // A fresh deployment warms its workspace with the current versions, the ones named here.
446530 const inputs = (
447531 kinds : readonly InputKind [ ] ,
@@ -504,10 +588,11 @@ export function createDeployTools(options?: {
504588 try {
505589 signal . throwIfAborted ( ) ;
506590 let result : unknown ;
591+ let canStart : readonly string [ ] = [ ] ;
507592 if ( call . name === "request_approval" ) {
508593 const { kinds } = validArguments ( RequestApproval , call . arguments ) ;
509- const { byKind , materials , openQuestions , preferences } =
510- await listed ( ) ;
594+ const state = await listed ( ) ;
595+ const { byKind , materials , openQuestions , preferences } = state ;
511596 const open = [ ...new Set ( kinds ) ] . flatMap ( ( kind ) => {
512597 const count = openQuestions . get ( kind ) ;
513598 return count
@@ -530,38 +615,17 @@ export function createDeployTools(options?: {
530615 return materials ;
531616 } ) ;
532617 await recordApproval ( "request_approval" , documents ) ;
533- result = { approved : documents } ;
618+ for ( const { kind, version } of documents )
619+ state . approved . set ( kind , version ) ;
620+ canStart = startable ( state ) ;
621+ result = { approved : documents , canStart } ;
534622 } else if ( call . name === "deploy_writer" ) {
535623 const input = validArguments ( DeployWriter , call . arguments ) ;
536- const { byKind, approved, preferences } = await listed ( ) ;
624+ const state = await listed ( ) ;
625+ const { byKind, preferences } = state ;
626+ const refusal = writerGate ( state , input . kind ) ;
627+ if ( refusal ) throw refusal ;
537628 const briefRef = required ( byKind , "problem_brief" ) ;
538- const earlier = DOCUMENT_ORDER . slice (
539- 0 ,
540- DOCUMENT_ORDER . indexOf ( input . kind ) ,
541- ) ;
542- // An imported project may lack an earlier document that its later ones were written without.
543- const writtenWithout = ( index : number ) =>
544- DOCUMENT_ORDER . slice ( index + 1 ) . some ( ( kind ) => byKind . has ( kind ) ) ;
545- for ( const [ index , kind ] of earlier . entries ( ) ) {
546- const ref = byKind . get ( kind ) ;
547- if ( ! ref ) {
548- if ( writtenWithout ( index ) ) continue ;
549- throw new Refusal (
550- kind +
551- "_missing: " +
552- ( kind === "design"
553- ? "have the designer make it with design_app"
554- : "write and file the " + kind ) +
555- " before the " +
556- input . kind ,
557- ) ;
558- }
559- if (
560- ! agentDecidesApprovals ( preferences ) &&
561- approved . get ( kind ) !== ref . version
562- )
563- throw notApproved ( kind , ref . version ) ;
564- }
565629 const target = byKind . get ( input . kind ) ;
566630 const sourceRefs = [ ...byKind ]
567631 . filter (
@@ -623,26 +687,17 @@ export function createDeployTools(options?: {
623687 options . projectId + ":coder" ,
624688 "coder" ,
625689 async ( ) => {
626- const { byKind, approved, inFlight, preferences } =
627- await listed ( ) ;
628- for ( const kind of CODER_DOCUMENTS ) required ( byKind , kind ) ;
690+ const state = await listed ( ) ;
691+ const { byKind, inFlight, preferences } = state ;
629692 const live = inFlight . find ( ( run ) => run . role === "coder" ) ;
630693 if ( live )
631694 throw new Refusal (
632695 "coder_already_running: the coder is building in run " +
633696 live . runId +
634697 "; send it changes with instruct_coder" ,
635698 ) ;
636- // The design is approved by choosing it, whoever decides the other documents.
637- for ( const kind of DOCUMENT_ORDER ) {
638- const { version } = required ( byKind , kind ) ;
639- if (
640- ( kind === "design" ||
641- ! agentDecidesApprovals ( preferences ) ) &&
642- approved . get ( kind ) !== version
643- )
644- throw notApproved ( kind , version ) ;
645- }
699+ const refusal = coderGate ( state ) ;
700+ if ( refusal ) throw refusal ;
646701 const design = required ( byKind , "design" ) ;
647702 return deploy (
648703 "coder" ,
@@ -696,8 +751,10 @@ export function createDeployTools(options?: {
696751 options . projectId + ":designer:" + concept . label ,
697752 "designer" ,
698753 async ( ) => {
699- const { byKind, inFlight, preferences } = await listed ( ) ;
700- for ( const kind of DESIGNER_INPUTS ) required ( byKind , kind ) ;
754+ const state = await listed ( ) ;
755+ const { byKind, inFlight, preferences } = state ;
756+ const refusal = designGate ( state ) ;
757+ if ( refusal ) throw refusal ;
701758 const message =
702759 designLookIdeas ( input . lookIdeas ) +
703760 coderTaskMessage ( input . instruction ) ;
@@ -832,7 +889,11 @@ export function createDeployTools(options?: {
832889 content : "unknown_deploy_tool" ,
833890 isError : true ,
834891 } ;
835- return { callId : call . id , content : JSON . stringify ( result ) } ;
892+ return {
893+ callId : call . id ,
894+ content : JSON . stringify ( result ) ,
895+ ...( canStart . length ? { detail : { canStart } } : { } ) ,
896+ } ;
836897 } catch ( error ) {
837898 return {
838899 callId : call . id ,
0 commit comments